Every single-box PaaS demo ends in the same place: one server, green checkmarks, HTTPS. Coolify passed 55,000 GitHub stars delivering exactly that moment. But the invoice for your second server is never the hardware — a Hetzner box costs less than lunch. The real bill is the re-platforming: moving live workloads off a tool that manages the box you have onto something that manages machines plural, declaratively. That migration currently means doing it by hand, and this post is the playbook.
The whole thing in one paragraph: stand a Cluster-API-managed fleet up alongside the old box, translate each workload from compose files and proxy labels into Kubernetes manifests, migrate data while the old box still serves traffic, shift production over with a weighted DNS cutover, and keep the old box warm as instant rollback until the new deploy pipeline proves itself. The sections below expand each phase into concrete commands and checklists — but first, the honest part: how to tell whether you need to move at all.
Should you move at all?
Most teams that think they need a second machine actually need a bigger first machine, a quieter neighbor, or one feature their PaaS already ships. Migrating for fashion buys you distributed-systems failure modes at single-box scale. Migrate for one of the reasons on the right side of this table, and stay put for anything on the left.
| Stay on one box (or your tool's multi-server mode) | Move to multi-machine scheduling |
|---|---|
| One app is slow → resize to a bigger box; a 4-vCPU Hetzner box is still under €10/mo | You need replicas of one app spread across failure domains, not just a bigger box |
| Predictable traffic with occasional spikes → vertical headroom is cheaper than orchestration | Deploys must survive a dead machine without human intervention (self-healing, rescheduling) |
| A second workload needs isolation → your tool's remote-server mode covers this today | Tenants or teams need scheduling isolation (noisy-neighbor guarantees, resource quotas) |
| You want "high availability" as a checkbox, with no measured downtime cost | Measured downtime has a cost and single-host maintenance windows already hurt |
| Your database fits comfortably on the box with backups to object storage | Your stateful layer needs topology (read replicas, volume replication) the box can't express |
The sharpest question in the table is the second row on the right: must a dead machine heal itself? Every tool below can put apps on two servers. None of them — short of full Swarm clustering, with Swarm's own maintenance-mode caveats — reschedules failed work, re-attaches storage, and re-converges proxy config the way a reconciling control plane does. If your answer is "the site can be down until I wake up and redeploy," you do not need this migration yet. If it isn't, keep reading.
What your current tool already gives you
Before crossing the gap, know exactly where your tool's bridge ends. All three cover multi-server placement; all three stop short of declarative multi-machine lifecycle — there is no "declare the desired fleet, let controllers converge it" loop.
Coolify supports single servers, additional remote servers, and Docker Swarm clusters, with Kubernetes support officially "coming soon" in its docs. Swarm mode inside Coolify is still experimental, and Swarm itself sits in long-term maintenance: Mirantis guarantees support through 2030 with security updates and modest runtime improvements, but the ecosystem's feature momentum moved to Kubernetes years ago. Coolify's model is fundamentally "Coolify SSHes into boxes and runs Docker commands" — great operability, no reconciliation loop.
Dokploy has the clearest three-tier story: everything on the Dokploy host, remote servers (independent boxes over SSH, each running its own apps), or Swarm nodes joined into one cluster for replicated services. Remote servers solve "my workloads don't fit on one box" without solving "one control plane schedules across boxes" — and its own issue tracker shows the seams (registry credentials that don't reach every node, no service transfer between non-Swarm servers).
CapRover is Swarm-native from the start: join workers from the Cluster page, raise the instance count, and Captain spreads replicas with load balancing and replacement on failure. That is genuine clustering — but it is Swarm clustering, inheriting Swarm's maintenance-mode feature set, and CapRover's Nginx-based routing and one-click-app model don't translate to anything outside CapRover.
So the ceiling is the same shape in all three: you can run things on N machines, but you cannot declare a fleet and have a machine lifecycle — provisioning, health-checking, rolling OS upgrades, autoscaling — converge it. The playbook below buys you that loop with Cluster API on Hetzner (CAPH), and each phase keeps the old box serving until the new side earns production.
The playbook
Phase 0: Inventory what you actually run
Migrations fail on forgotten state, not on YAML. Before touching the fleet, write down everything the box does that isn't "run this container":
- App definitions and build sources. Every compose file, Dockerfile, buildpack config, and — critically — where images get built. On-box builds are the default in all three tools; that pipeline moves to CI plus a registry in Phase 2.
- Named volumes and bind mounts, with sizes.
docker system df -vis your friend. Note which volumes hold replaceable cache versus irreplaceable data. - Environment and secrets. Where do they live — the PaaS UI,
.envfiles on disk, both? Anything hand-pasted into a dashboard needs a new home. - Cron and scheduled jobs. Recurring tasks are the classic forgotten workload; list every schedule and what it touches.
- Domains and TLS. Every hostname, who issues its certificate (Traefik and CapRover both wrap ACME), and expiry dates. New infrastructure means re-issuance, and Let's Encrypt's rate limits (50 certificates per registered domain per week) bite teams with many subdomains who re-issue carelessly.
- Stateful services. Postgres, MySQL, Redis, object buckets — version, size, and the current backup story. Measure a restore now; "we have backups" is not the same as "we restored one this quarter."
Phase 1: Stand the fleet up alongside
The fleet goes up next to the old box, not in place of it. Cluster API's model needs a management cluster first: the standard bootstrap is a throwaway kind cluster on your laptop or CI runner, clusterctl init with the Hetzner infrastructure provider, generate the workload cluster, then pivot management onto the new cluster itself so there is no permanent extra dependency:
kind create cluster --name caph-bootstrap
clusterctl init --core cluster-api --bootstrap kubeadm \
--control-plane kubeadm --infrastructure hetzner
export HETZNER_HCLOUD_TOKEN="<token with server + network rights>"
clusterctl generate cluster prod --flavor hetzner-hcloud-control-planes \
--kubernetes-version v1.36.0 > prod.yaml
kubectl apply -f prod.yaml
# once the workload cluster is up, pivot management onto it:
clusterctl move --to-kubeconfig "$PROD_KUBECONFIG"A minimal starting topology is one control-plane node plus two workers on Hetzner Cloud — be honest with yourself that a single control plane is not highly available, it is a starting point. Day-one addons after the nodes join: a CNI, the Hetzner cloud-controller-manager (load balancers, node addressing), the Hetzner CSI driver (persistent volumes), and an ingress controller. Verify with a canary app on a staging subdomain before any production workload moves: if the fleet can't serve staging reliably for a week, it isn't ready for Phase 4.
Phase 2: Translate workloads
This is the mechanical heart of the migration. Your compose files and proxy labels are the spec; Kubernetes manifests are the port. kompose convert gives a first-pass translation of compose files into Deployments, Services, and PVCs — treat its output as scaffolding to hand-tune, never as production YAML. The mapping table:
| Single-box concept | Fleet equivalent |
|---|---|
| Compose service | Deployment + ClusterIP Service (replicas ≥ 2 once proven) |
| Traefik router labels (Coolify, Dokploy) | IngressRoute/Ingress — or keep Traefik itself as your ingress controller and port the labels almost verbatim |
| CapRover Nginx routing + one-click apps | Ingress resources + Helm charts for the packaged apps |
| Named volume | PersistentVolumeClaim via the Hetzner CSI driver |
| Env vars / dashboard secrets | Sealed Secrets or External Secrets Operator — nothing hand-pasted survives re-platforming twice |
| On-box builds | CI pipeline (GitHub Actions or your runner) pushing to a registry; the cluster only ever pulls immutable tags |
| Cron jobs | CronJobs, with concurrencyPolicy set deliberately and disabled on the old box at cutover |
Two translation gotchas deserve emphasis. First, builds: the single-box habit of "push code, the box builds it" must die here. The fleet pulls images; nothing builds on cluster nodes. Second, anything stateful that assumed a local disk path needs its storage story redesigned around PVCs or object storage — a container that wrote to /data on the old box gets a PVC, and anything serving user uploads should move to S3-compatible object storage before cutover so files stop being box-local at all.
Phase 3: Move the data
Data migrates while the old box still serves production. Pick the strategy by size and downtime budget:
- Small databases (minutes of dump/restore):
pg_dump→ transfer →pg_restoreinto the new Postgres during a maintenance window. Time a full rehearsal run first — dump, transfer over the real network path, restore — and budget the window at 2× the measured time. - Large or always-on databases: logical replication from the old primary to the new one, then promote the replica during a short write-freeze window. More setup, dramatically shorter cutover.
- File data:
rsyncin passes — a big initial sync days ahead, then fast delta syncs, with the final delta inside the cutover window. - Caches and sessions: plan to cold-start Redis rather than migrate it, unless session continuity is load-bearing. Flushing sessions during a cutover is a known, bounded pain; a half-migrated session store is not.
Run the new datastore as the replica/standby before cutover day so lag is a visible metric, not a day-of surprise. And decide the write-drain direction per datastore in advance: during a weighted cutover, writes land on both sides, and "we'll reconcile later" is how you lose rows.
Phase 4: Weighted cutover with the old box as rollback
Lower DNS TTLs (300 seconds) at least 48 hours before cutover. Then:
- Point a staging subdomain at the fleet and run real traffic through it — your own smoke tests plus, if you can, mirrored production traffic.
- Shift a weighted slice of production DNS to the fleet's load balancer (10% → 50% → 100%), watching error rates, latency percentiles, and application-level success metrics at each step, not just "the site loads."
- At 100%, freeze deploys on the old box but keep it running and warm for one to two weeks — warm means the apps still start, the database still accepts connections, and you could flip DNS back in minutes.
- Rollback, if needed, is a DNS flip back to the old box — plus replaying any writes that landed on the new side during the split, which is why Phase 3 made you plan write-drain direction per datastore.
- Decommission only after the new deploy pipeline (CI → registry → cluster) has shipped several uneventful releases: snapshot the old volumes, export anything of forensic value, then terminate.
The sharp edges live in steps 2–4. Background workers must not double-run on both sides — give fleet workers idempotent handlers or a leader election before the split, and pause the old side's workers the moment writes move. Cron must run in exactly one place; disable each schedule on the side that loses. And TLS re-issuance on new IPs is routine until it isn't: pre-issue certificates for every domain on the fleet before shifting traffic, and stagger issuance if you have dozens of subdomains sharing a registered domain.
What it costs, and what changes after
The machine math, worked honestly with current Hetzner Cloud list prices (excl. VAT; they move — recheck before budgeting). A typical starting point is one 4-vCPU/8-GB box at roughly €9/mo. A minimal non-HA fleet — one control-plane node plus two 2-vCPU/4-GB workers — lands around €18/mo, plus a small load-balancer fee. During the one-to-two-week overlap you pay both sides, call it one extra month of the old box. So the migration costs roughly one month at ~€27, then ~€18–20/mo steady state — about double the single-box bill.
The range matters more than the point estimate: that minimal fleet has a single control plane, which is a starting topology, not an HA story. Three control-plane nodes push steady state toward €30–40/mo. Conversely, ARM workers trim a euro or two per node. The honest framing is a range — ~€18/mo minimal, ~€35/mo with an HA control plane — against ~€9/mo for the box you came from. The delta buys declarative machine lifecycle, not raw compute.
What changes operationally is the actual product. Deploys become GitOps (push a manifest change, watch it roll out) instead of dashboard clicks. Node failures reschedule instead of paging you. OS and Kubernetes upgrades roll through MachineDeployments instead of in-place SSH sessions. Cluster-autoscaler can grow owned machines with demand. What you lose is real too: one-click databases and one-click apps become Helm charts and operators you own; on-box builds become a CI pipeline you maintain; the PaaS log viewer becomes an observability stack you choose. Go in with eyes open — the fleet is more powerful and more yours, which cuts both ways.
Cutover week is the wrong time to discover your new platform's rough edges. Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own, with the declarative multi-machine model this playbook builds toward already in place. Star the repo on GitHub or deploy your first app today.



