There is a specific invoice that starts this conversation. You are not on your first app anymore. You have a web service, a couple of background workers, a database, maybe a second small project sharing the account, and the monthly bill has crept from pocket change to something you actually notice: fifty, eighty, a hundred dollars, climbing every time you add a piece. None of it is unreasonable per line item. It is just that the meter never stops, and a growing share of it is platform margin rather than the machines your code runs on. Around the third or fourth app, a lot of developers look at that number and start wondering what it would take to run this stuff themselves.
This is a guide to doing exactly that, moving from a PaaS to a cluster you own, without pretending it is free or instant. It is real work, and for one small app it is honestly not worth it yet. So let me start there, with the case against.
When you should not do this
A PaaS genuinely wins for the first app. As of July 2026, most of the well known platforms will run a single small web service for around five dollars a month, some with a free tier that covers hobby traffic, and several bundle a small managed database at no cost or close to it. That is a very good deal. You get deploys, HTTPS, and a database without touching a server, and the operational work is somebody else's job. If you are pre launch, or you run exactly one modest service and a starter database, stay where you are. Moving would cost you a weekend to save money you are not really spending, and that is a bad trade.
The math changes as you add pieces. PaaS pricing is priced per app, per worker, per add on, per unit of usage, and those lines add independently. Your own cluster is priced per machine. One box can hold your web service, your workers, and your database at once, and adding the fourth app to it is close to free because you already paid for the box. So the honest pitch is not "a PaaS is expensive." It is "a PaaS is cheap for one thing and gets linear as you grow, while a cluster is a fixed floor you keep filling." Somewhere around the third or fourth app, those two lines cross, and after the crossing you are paying flat while your PaaS bill keeps climbing. That crossing, plus wanting to own where your code and data live and not be locked into one vendor's shape, is the whole case. If you are past it, read on.
Burrow is the tool this guide uses to make the move manageable. It installs onto a cluster you own, you keep root, and it carries the day two operations (deploys, rollbacks, logs, scaling, certificates, backups) with plain commands instead of a wall of Kubernetes. It is open source and self hosted, so there is no rented brain in the middle and nothing to lock you in a second time. With that said, here is the path.
Step 1: Take stock of what you actually run
Before you move anything, write down everything the PaaS is doing for you. This sounds obvious and it is the step people skip, then discover a forgotten cron job in production three weeks later. Open your PaaS dashboard and inventory:
- Every running service: the web app, each background worker, any scheduled jobs.
- Every managed add on: the database, any cache or queue, object storage.
- Every environment variable and secret, per service, and which are non secret config (a log level, a feature flag) versus real secrets (a database URL, an API key).
- Every domain and subdomain pointed at the platform, and where the DNS for each is hosted.
- The container image or build for each service. If the PaaS builds from your source, note the language and how it starts.
You do not have to migrate all of it at once. In fact you should not. But you need the full list so that "move the app" does not quietly drop a worker or a cron on the floor.
Step 2: Get a cluster
You need a machine to run on, and here the word "cluster" scares people more than it should. A single cheap VPS is a real cluster. One box from any provider, in the five to twelve dollars a month range, is enough to run several services and a database for a small operation. You do not need three nodes and a load balancer to start. You need one machine you control.
If you already have a managed Kubernetes cluster (on DigitalOcean or similar), that works too, and it is the right choice once you outgrow a single box or want the provider to handle node upgrades for you. But do not reach for it on day one if a single VPS covers you. Start with one machine, prove the move, and grow the cluster when the load actually asks for it.
Step 3: Install Burrow onto it
With a single fresh VPS, one command turns that bare machine into a working cluster with Burrow on it:
burrow cluster bootstrap
That stands up a lightweight k3s cluster on the box and installs Burrow into it. A single node's public IP can serve a free load balancer through the built in service load balancer, so you are not paying a cloud provider extra for the privilege of reaching your own app.
If you brought your own existing cluster instead, you point Burrow at it and install:
burrow install
Either way, the result is the same: Burrow is now running on infrastructure you own, holding the credentials and doing the operating from here on, and you still have root on the machine. Nothing about this hands control to an outside service.
Step 4: Containerize, if you are not already
A PaaS often builds from your source, so you may never have written a Dockerfile. Your own cluster deploys container images, so if you do not have one yet, this is the step where you make one. For most apps it is short. A small web service in a typical language needs a Dockerfile that installs dependencies, copies your code, and sets the start command, then builds to an image. If your PaaS already deploys from a Dockerfile, you are done here, use the same one.
This is genuinely the step with the most variation, because it depends on your stack. The goal is a single command that produces an image you can run anywhere:
docker build -t ghcr.io/you/web:1.0.0 .
docker push ghcr.io/you/web:1.0.0
Push it to a registry your cluster can pull from. Once your app is an ordinary image, it is portable by construction, which is also what makes the move reversible later.
Step 5: Deploy by image reference
With an image in a registry, deploying is one command per service. You name the app and point at the image:
burrow app deploy web --image ghcr.io/you/web:1.0.0
Do this for each service from your step one inventory: the web app, then each worker, each as its own app pointed at its own image. You can pass initial environment values inline with --env as you go, though the cleaner path for anything sensitive is the next step. At this point your code is running on your cluster, even if it is not yet serving real traffic or wired to its data. That separation is deliberate: get the app up first, then move config, then data, then flip traffic to it, so that each step is verifiable on its own.
Step 6: Move config and secrets
Now bring over the environment from step one, and keep the two kinds separate. Non secret config goes in plainly:
burrow app config set web LOG_LEVEL=info
burrow app config set web FEATURE_NEW_CHECKOUT=true
Real secrets go through their own lifecycle, and this is where the design matters. You set a secret's value yourself, at your own terminal, and it flows through Burrow's own API straight into the cluster. It never travels through any agent conversation and it is never written to logs:
burrow app secret set web STRIPE_API_KEY
burrow app secret set web SESSION_SIGNING_KEY
Work through your inventory service by service. The database URL is the one secret you should not copy from the old provider by hand, because in the next step Burrow is going to generate a fresh one for you when you attach a database it manages.
Step 7: Attach a database and restore your data
If your app needs Postgres, you do not have to run a database server by hand. Install the addon once for the cluster, then attach it to the app that needs it:
burrow addon install postgres
burrow addon attach postgres web
Attaching injects a generated DATABASE_URL into the app, so your web service and workers can find the database without you managing that connection string yourself. That is why you did not copy the old one over.
Now move your actual data. Export it from your old provider with a standard dump, then load it into the new database. Burrow runs restores behind a confirm guardrail, so a destructive load asks you to sign off before it touches anything:
pg_dump "$OLD_DATABASE_URL" > backup.sql
burrow addon postgres restore web < backup.sql
Do this while the old app is still live and serving. Your data now exists in both places, and you have not cut anything over yet, so there is no downtime and no risk to production traffic. Verify the row counts and spot check a few records in the new database before you go further.
Step 8: Publish with a domain and TLS
Your app is running and has its data, but it is only reachable by IP. Give it a real address with HTTPS. Burrow handles the certificate and the DNS wiring for you, with automation for the common DNS providers (DigitalOcean and Cloudflare among them):
burrow app publish web app.example.com
Certificates are issued and renewed for you through cert-manager, so you are not chasing expiring certs by hand on day two. At this point you can hit the new URL over HTTPS and exercise the app end to end: log in, click through the real flows, confirm it is talking to the restored database correctly. Do all of this before a single real user is pointed at it.
Step 9: Cut over DNS
This is the moment traffic actually moves, and it is the one to do calmly. Lower the DNS time to live on your production domain a day ahead, so that when you flip the record, clients pick up the change quickly instead of caching the old address for hours. Then point your production domain at the new cluster.
Because DNS changes propagate gradually, you will have a window where some requests still land on the old PaaS and some on your cluster. This is exactly why you kept both databases in sync only up to the cutover, and why you should pick a quiet hour to flip. For most small apps a brief maintenance pause during the cutover, so that writes do not split across two databases, is the honest and simple choice. Announce a short window, flip the record, and watch the traffic arrive on the new box.
Step 10: Verify, then decommission the old PaaS
Do not delete anything yet. Once DNS has fully propagated and traffic is landing on your cluster, watch it for a while with the day two commands, which are one line each:
burrow app status web
burrow app logs web
Confirm health is green, logs are clean, the workers are processing, and the database is taking writes. Let it run for a few days under real traffic before you touch the old setup, because the old PaaS is your rollback: if something is wrong, you flip DNS back and you are exactly where you started, which is the whole reason you did not tear it down on cutover day.
When you are satisfied, then you decommission. Scale the old services to zero, take a final backup of the old database and keep it somewhere safe, and cancel the paid add ons and services on the PaaS one at a time, checking each cancellation does not break something you forgot. When the last piece is off, the monthly bill that started this whole thing stops.
What you actually bought, and what you did not
Be honest with yourself about the trade you just made. You traded a bill that grew with every app for a fixed floor you now fill for close to free, you traded a platform's shape and edges for a cluster that runs anything, and you traded a vendor's account for machines you own with no lock in. Those are real wins, and they get better with every app you add to the box.
What you did not buy is a free lunch. You now own the cluster itself: the machine, its operating system updates, and the bill from whoever rents you the box. Burrow carries the app level day two work (deploys, rollbacks, logs, scaling, autoscaling since v0.8, certificates, and managed addons like Postgres), but it does not run your cloud account for you. Some finer grained controls, per service resource limits among them, are still coming rather than shipped, so if your setup leans on one of those, check where it stands before you count on it. The move is worth it when the things you need are the things that already work, and when the invoice that started this is big enough that a fixed floor beats a climbing meter. At the third or fourth app, for a lot of people, it is. At the first, it is not, and there is no shame in staying put until then.