How I Turned a VPS into a Platform

By Pablo Fernandez7 min read

The problem neither side solves#

For the apps I ship, both ends of the market are a bad fit.

The managed platforms (Vercel, Fly, Railway) are wonderful at making deploys disappear, but you don't own the machine. You pay per resource and per unit of usage, egress included, you rent a data layer instead of running one, and the bill grows with success. That's the right deal when you want zero operations. It stops being the right deal when you want a real Postgres, persistent services, and a flat cost you control.

Heavy self-hosting is the mirror image. You own the box, but the tools that get you there are a lot of platform. Something like Coolify runs a container per database, hides the reverse proxy config behind its own UI, and gives you less visibility into what's actually running or whether a backup succeeded than I was comfortable with. For a handful of lean apps, that's more machinery than the job needs.

So the gap is specific: I wanted the ergonomics of a PaaS on a machine I fully own, without dragging a whole panel along to get it.

The design decision#

Instead of adopting a platform, I encoded one. pfrctl is a single Go binary that treats a whole VPS as infrastructure I operate from one place. That's the whole bet, and it's worth stating plainly because everything else follows from it: the operations that normally send you running to a PaaS (deploy, TLS, databases, backups, recovery, DNS, secrets) become single commands, so owning the box stops costing me a weekend every time something moves.

It runs either locally or over SSH. From my Mac each command becomes ssh <vps> docker … using my SSH config; on the box it talks to Docker directly. There's no agent and nothing to install on the app side.

┌──────────────┐ │ ▸ pfrctl │ │ one Go bin │ │ $ _ │ └──────────────┘ │ ssh · docker┌────────────────────────────────┐platform│ traefik · registry · watch │├────────────────────────────────┤data-stack│ postgres · redis · nats ││ meilisearch · pgbouncer │├────────────────────────────────┤apps│ web · api · jobs · … │└────────────────────────────────┘
One binary on my Mac drives one box: shared platform, shared data-stack, and the apps on top.

What pfrctl actually is#

One box runs the shared platform (Traefik, a private registry, Watchtower) and every project on top of it. The CLI is the whole surface:

$_terminal
Bash
pfrctl status                 # containers + last backup
pfrctl deploy <image> --url  # ship an app behind HTTPS
pfrctl tenant create <name>   # an isolated tenant across 4 engines
pfrctl backup full            # pgBackRest → Backblaze B2
pfrctl dr snapshot            # disaster-recovery snapshot
pfrctl migrate <db> --to    # zero-downtime move to another node
pfrctl hetzner create       # provision a new VPS from the CLI
pfrctl foreach status         # run a command across every server

Shipping an app#

Point it at an image and a hostname:

$_terminal
Bash
pfrctl deploy registry.example.com/app/web:latest --url app.example.com --port 3000 \
  -e NODE_ENV=production

It writes a compose file with Traefik labels, joins the container to the shared proxy network, waits for Let's Encrypt, and wires it to Watchtower so a new image on the same tag redeploys itself. No dashboard, no hand-written YAML, no build minutes.

pfrctl deploy <image> --url app.example.com --port 3000 ├─ docker pull ├─ write compose (Traefik labels) ├─ join the shared proxy network └─ wait for Let's Encrypt┌──────────────────────────────────────────┐https://app.example.com│ HTTPS · auto-cert · Watchtower updates │└──────────────────────────────────────────┘
pfrctl deploy: an image and a hostname in, an HTTPS app out.

The shared data-stack, precisely#

This is the part I'm proudest of and the part most worth scrutinising. Instead of a database per app, one data-stack runs on the box: Postgres (with pgvector, PostGIS and pg_cron), Redis, NATS, and Meilisearch, all behind pgBouncer. A new project doesn't add containers. It gets a tenant:

$_terminal
Bash
pfrctl tenant create acme
<>
Code
DATABASE_URL=postgres://acme:…@pgbouncer:6432/acme
REDIS_URL=redis://acme:…@redis:6379/0
NATS_URL=nats://acme:…@nats:4222
MEILI_KEY=…      MEILI_INDEX_PREFIX=acme-

What's shared is the compute: the four engine processes, their memory, and the box itself. What's separated per tenant is the authority inside each engine, and it's worth being exact about which guarantee each layer gives:

  • Postgres: its own database and role. Isolation is a real security boundary. A tenant's role can't read another tenant's database.
  • Redis: an ACL user locked to the acme:* keyspace. Isolation is by convention plus enforcement. The user physically cannot touch keys outside its prefix, but the prefix is the fence, not a separate instance.
  • NATS: an isolated account with its own subject space and JetStream. Accounts are a hard boundary. Subjects don't cross between them.
  • Meilisearch: a scoped API key limited to acme-* indexes. The weakest of the four: it's key-scoped access to a shared index namespace, so it leans more on the prefix discipline than on engine-level walls.
pfrctl tenant create acme┌──────────────────────────────────────────┐│ postgres database + role ││ redis ACL user · keys acme:* ││ nats account · own subjects ││ meili API key · indexes acme-* │└──────────────────────────────────────────┘one connection bundle · zero new containers
One command fans out across four shared engines, then hands back one bundle. No new containers.

Being honest about the limits matters more than the pitch. Because the engines are shared, one tenant's runaway query or index can starve the others: the isolation is of authority, not of resources. A restore is coarse at the Postgres layer (point-in-time is cluster-wide through pgBackRest), so recovering a single corrupted tenant to a moment in time is not as clean as it would be with a database per app. And uneven growth is a real risk: the tenth project is nearly free until one project's data or traffic dwarfs the rest, at which point "shared" turns into "noisy neighbour" and that project probably wants to graduate to its own box. The model is excellent for many small, well-behaved tenants and a poor fit for one that quietly becomes large.

Operating it for real#

Deploying is the easy half. The reason I trust one box is that the operations around it are also one command each. Backups are pgBackRest to Backblaze B2 with point-in-time recovery, plus Redis and Meilisearch snapshots, on a daily cron. pfrctl dr snapshot pushes the whole state to B2 and can restore it onto a fresh VPS. pfrctl migrate moves a database to another node with zero downtime through logical replication, and pfrctl migrate full moves an entire box to a new provider, orchestrating the bootstrap, the private mesh, the data, the apps and the DNS cutover in order. Nodes talk over a private Tailscale mesh, so changing providers is a command rather than a rewrite. Secrets live in Git, encrypted with SOPS and an age key, and get pushed to the box.

pfrctl migrate full old box ▶ new provider ├─ 1 bootstrap provision the box, install the platform ├─ 2 private mesh join the Tailscale network ├─ 3 data replicate the data-stack ├─ 4 apps redeploy every tenant └─ 5 DNS cutover point the records at the new boxsame platform on a new provider · zero rewrite · one command
pfrctl migrate full moves a whole box to a new provider, one ordered step at a time.

The honest comparison#

None of this makes the managed platforms wrong. For most teams the maths favours them, and it should.

Vercel / Fly / RailwayCoolifypfrctl
Ops burdennonelowyou run the box
Data-stackrented, per-resourcea container per databaseone shared stack, tenants
Cross-provider movelock-inmanualone command
Cost modelper resource + egressyour serveryour server, flat
Driven bydashboard + gitUIa scriptable CLI

Coolify deserves the narrow critique, not a broad one. It's genuinely good, and the two differences that pushed me elsewhere are structural, not cosmetic: it models data as a container per database rather than one stack many tenants share, and it's a panel rather than a binary I can script and run across a fleet.

When it's worth it, and when it isn't#

The rule I'd give someone else is short:

  • A managed PaaS when you want maximum comfort and are happy to pay per resource for it.
  • Coolify when you want to self-host with a UI and don't mind a container per database.
  • pfrctl when you want total control from one CLI on a single box, and you're willing to be the one who runs it.

The cost case is only compelling for the right profile. One Hetzner box (about seventeen euros a month on my old plan, closer to thirty-six today) carries the platform, every project, and every tenant, at a flat number. A comparable managed setup, a few small apps each with a managed Postgres and some egress, drifts into the tens of euros per app per month and climbs with traffic. For a handful of lean, steady apps the fixed cost wins comfortably. For spiky, scale-to-zero workloads, the managed autoscaling and the absence of an on-call human win instead.

Which is really the whole decision, stated as a trade rather than a feature list: you are choosing between comfort and control. A PaaS sells you convenience by renting you the machine. pfrctl keeps the machine and buys the convenience back one command at a time. Whether that's a good trade depends entirely on whether you'd rather never think about the box, or always be able to.

Related articles

Generating Vasarely in Code

How I rebuilt Victor Vasarely's Vega-Nor as a pure, deterministic TypeScript function, with OKLab paint ramps, an area-conserving spherical warp, and colorways generated from a seed. Every image here comes from a seed.

Marking Media So Leaks Have a Name

How I'm building Protomarc, a system that marks images, video, audio, and PDF so a file can prove who owns it and, if it leaks, point back to whoever it was given to. A high-level look at the problem and the hard parts.

Building APIs with Hono

How I build type-safe APIs with Hono: the router, the Context object, Zod validation, centralized error handling, and end-to-end types shared with the frontend.