---
title: How I Turned a VPS into a Platform
date: '2026-07-04'
description: 'A design decision, not a demo. Why I encoded a whole VPS as one Go CLI, pfrctl, instead of renting a PaaS or carrying a heavy self-hosted panel, and where that trade-off pays off.'
author: Pablo Fernandez
tags:
  - Infrastructure
  - Self-hosting
  - DevOps
  - Postgres
  - Go
source: 'https://www.pablofr.com/blog/pfrctl-infrastructure-as-code'
---
## The problem neither side solves

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

The managed platforms ([Vercel](https://vercel.com), [Fly](https://fly.io), [Railway](https://railway.com)) 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](https://www.postgresql.org), 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](https://coolify.io) 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](https://go.dev) 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](https://www.docker.com) 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](https://traefik.io), a private registry, [Watchtower](https://containrrr.dev/watchtower/)) and every project on top of it. The CLI is the whole surface:

```bash:terminal
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:

```bash:terminal
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](https://letsencrypt.org), 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](https://github.com/pgvector/pgvector), [PostGIS](https://postgis.net) and [pg_cron](https://github.com/citusdata/pg_cron)), [Redis](https://redis.io), [NATS](https://nats.io), and [Meilisearch](https://www.meilisearch.com), all behind [pgBouncer](https://www.pgbouncer.org). A new project doesn't add containers. It gets a *tenant*:

```bash:terminal
pfrctl tenant create acme
```

```text
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](https://pgbackrest.org)), 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](https://www.backblaze.com/cloud-storage) 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](https://tailscale.com) mesh, so changing providers is a command rather than a rewrite. Secrets live in Git, encrypted with [SOPS](https://getsops.io) and an [age](https://age-encryption.org) 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 box
              │
              ▼
  same 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 / Railway | Coolify | pfrctl |
|---|---|---|---|
| Ops burden | none | low | you run the box |
| Data-stack | rented, per-resource | a container per database | one shared stack, tenants |
| Cross-provider move | lock-in | manual | one command |
| Cost model | per resource + egress | your server | your server, flat |
| Driven by | dashboard + git | UI | a 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](https://www.hetzner.com) 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.
