The Control Plane
Managed hosting · from one VPS to a platform

The Control Plane

How Vercel, Laravel Cloud, Fly.io and Coolify turn other people’s code into running, routed, backed-up services — and how to do the same for a product like Instatic with a Ruby core, SQLite and static publishing, starting from one big VPS and growing from there without repainting the house.

blue = control plane, your software green = tenant workloads dashed = the internet, untrusted Ruby · Docker · Caddy · S3 read against your coolify clone
00

What “managed hosting” actually is

Strip the marketing off any PaaS and the same four verbs are left: build code into something runnable, run it somewhere, route traffic to it, and store its state so it survives. Everything else — dashboards, billing, autoscaling, preview environments — is a feature layered on those four.

What separates a platform from a bash script is who does those verbs and when. On your laptop you do them by hand, once. A platform does them on behalf of thousands of tenants, continuously, in response to state changes, and it does so through three distinct layers that are worth naming precisely because every architecture in this document is a variation on them.

EDGE · WHERE THE INTERNET TOUCHES YOU DNS anycast IP / CDN / Cloudflare TLS termination DDoS · WAF · cache CONTROL PLANE · YOUR SOFTWARE desired state: tenants, plans, domains, versions, placements reconcilers: build · place · route · back up · bill DATA PLANE · WHERE TENANTS RUN nodes: proxy + containers + volumes object storage: backups · uploads · published static files requests configures creates DNS records, requests certs The control plane is never on the request path. If it goes down, every tenant keeps serving. That property is the whole design. The data plane holds no opinions. It runs what it is told, with what it is given, and reports back. Coolify: Laravel app (control) → SSH → Docker + Traefik on your servers (data). Laravel Cloud: Go operator (control) → EKS pods (data). Fly.io: flyd + corrosion (control) → Firecracker VMs (data).
Every platform in this document has exactly this shape. What differs is what fills each box: who owns the edge, whether the control plane talks to nodes over SSH or through an agent, and whether a tenant is a container, a pod, or a microVM.

The product this is written for

Instatic is a self-hosted CMS where the editor, the content engine and the publisher live in one server process, with SQLite as the database, an uploads folder for media, and published pages baked to plain HTML on disk. The stated backup unit is “the database file plus the uploads folder”. It deploys today as one container per site on Railway, Render or a VPS.

Your variant swaps the runtime for Ruby but keeps that shape, so this course assumes the following and says so up front rather than hedging for twelve sections:

Working assumption

One tenant = one instance of your Ruby application, running from the same platform image as every other tenant, with its own SQLite file and uploads directory on a private volume, and its own published static output. Tenants never share a process or a database. Visitors of a published site should be served from static files and a CDN, never from the Ruby process; the Ruby process exists for the editor and the publish step.

If your product is instead a single multi-tenant Rails app with a tenant_id column, most of the routing, TLS, storage and edge material still applies unchanged, but the “one container per customer” sections become “one container pool for everyone” and your problems move from scheduling to database design. §00’s table below is the fork.

Tenancy modelIsolationDensity (tenants per box)Per-tenant backupBlast radius of one bugWho does this
Instance per tenant this coursestrong — process, files, DB all separatetens active, hundreds idleCopy two paths. Trivial.One tenantInstatic, Ghost(Pro), WordPress hosts, Coolify apps
Multi-tenant applogical — one row filter away from a leakthousandsHard: one big DB, per-tenant export is a feature you buildEveryoneShopify, Notion, most SaaS
Hybrid — shared editor, per-tenant data storesmixedhighPer-tenant DB file, shared codeEveryone for code, one for dataTurso-style DB-per-tenant, Fly’s sprites
01

The anatomy of one hosted app

Before scaling anything, be precise about what a single running instance of your product actually needs from the machine underneath it. Every one of these becomes a column in a database table later, so it pays to enumerate them now.

one tenant ruby · puma · your app editor + publish, not visitors ~100–250 MB RSS idle reverse proxy domain → container:3000, TLS port env + secrets SECRET_KEY_BASE, S3 creds, plan health endpoint GET /up → 200, polled private disk (volume) db.sqlite3 · uploads/ · public/ local disk, never NFS reads, writes logs stdout → captured, rotated, shipped object storage backups · published site backup Eight things. Each one is a field on a tenant row, a line in a compose file, or both. Miss one and you will discover it at 3am.
The instance itself is the least interesting part. The eight things around it are the platform, and the reason a “just run it in Docker” plan takes six months.
a process
Puma running your app, one or two workers, a few threads. It must start in seconds, exit cleanly on SIGTERM, and never assume it is the only copy in the world.
a port
It listens on one internal port. Nothing on the internet talks to it directly; a reverse proxy in front owns the public ports, the domains and the certificates.
a disk
SQLite is a file. Uploads are files. Published output is files. They must live on a real local filesystem — SQLite over NFS or a network drive corrupts under concurrent writes. This single fact shapes the whole scaling story in §07 and §08.
configuration
Environment variables at start: keys, the tenant’s plan limits, object-storage credentials scoped to that tenant’s prefix. Never baked into the image.
a domain and a certificate
acme.yourplatform.app for free, www.acme.co.ke when they bring their own. Certificates are issued and renewed by the proxy, not the app.
logs
Write to stdout. The host captures, rotates and, later, ships them. An app that writes its own log files inside the container fills the disk on a Sunday.
a health check
One cheap endpoint that returns 200 when the app can serve. The proxy and the deploy process both depend on it; without it there is no zero-downtime anything.
a backup
Copy of the disk contents, off the machine, on a schedule, with a tested restore. Instatic’s own docs name the backup unit as database file plus uploads folder — keep it that simple.
The 12-factor tension

The classic advice for hosted apps is “processes are stateless, state lives in backing services”. Your product deliberately breaks that: SQLite is local state, and it is a feature — isolation, zero-config, trivially portable, no database server to run. The price is that a tenant is pinned to the node that holds its file, and moving a tenant means moving a directory. Everything in this course is designed around accepting that price knowingly rather than fighting it.

02

Containers: the unit of packaging

Containers won hosting not because they are clever — a container is a process with a restricted view of the filesystem, network and process table — but because an image is a complete, immutable, content-addressed description of everything a process needs. That makes “run this on that machine” a solved problem, and it is the reason every platform in §09 speaks OCI images even when it runs them in something other than Docker.

Five words

image
A stack of read-only filesystem layers plus metadata (entrypoint, env, ports). Identified by a content digest (sha256:…); tags like :v1.4 are mutable pointers to a digest. Deploy by digest, never by tag.
container
A running (or stopped) instance of an image with a thin writable layer on top. Disposable by design. Anything written inside it and not to a volume is gone on restart.
volume
A host directory mounted into the container that outlives it. Where SQLite and uploads live. Named volumes under /var/lib/docker/volumes or bind mounts to a path you choose.
network
A private virtual network. Containers on the same one reach each other by name. Tenants should not share one, or tenant A can talk to tenant B’s port 3000.
registry
Where images are pushed and pulled from: Docker Hub, GHCR, ECR, or a self-hosted one. The platform’s release artefact lives here.

How code becomes an image

MethodWhat it isWho controls the resultCoolify enum
DockerfileExplicit recipe you writeYou, fullydockerfile
Buildpacks (Nixpacks, Railpack, CNB)Detect the language, generate the recipeThe buildpack; you tweak with confignixpacks, railpack
ComposeSeveral images plus their wiringYoudockercompose
StaticBuild output copied into an nginx/Caddy imageThe platformstatic
Prebuilt imageNo build; pull from a registryWhoever published it— (image source)

Those five are literally the cases in app/Enums/BuildPackTypes.php in your Coolify clone, and the deploy_*_buildpack() methods in ApplicationDeploymentJob are one per row. Vercel and Laravel Cloud do the same detection with their own build systems. For your product, only the last row matters: you build your platform image once per release and run it N times. Tenants never build anything — which removes the single most complex subsystem of a general-purpose PaaS from your plate.

dockerfilethe platform image  ·  one build, every tenant
# ---- build stage: compile gems and assets, then throw the toolchain away
FROM ruby:3.4-slim AS build
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
      build-essential libsqlite3-dev libyaml-dev git && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local deployment true && \
    bundle config set --local without 'development test' && \
    bundle install -j4
COPY . .
RUN SECRET_KEY_BASE=dummy bundle exec rails assets:precompile

# ---- runtime stage: small, non-root, read-only except /data
FROM ruby:3.4-slim
RUN apt-get update -qq && apt-get install -y --no-install-recommends \
      libsqlite3-0 libyaml-0-2 curl tini && rm -rf /var/lib/apt/lists/* \
    && useradd -r -u 10001 -d /app app
WORKDIR /app
COPY --from=build --chown=app:app /app /app
COPY --from=build /usr/local/bundle /usr/local/bundle

ENV RAILS_ENV=production RAILS_LOG_TO_STDOUT=1 \
    DATABASE_URL=sqlite3:/data/db/production.sqlite3 \
    UPLOADS_DIR=/data/uploads PUBLISH_DIR=/data/public PORT=3000
VOLUME ["/data"]
EXPOSE 3000
USER app
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s \
  CMD curl -fsS http://127.0.0.1:3000/up || exit 1
ENTRYPOINT ["tini","--"]
CMD ["bundle","exec","puma","-C","config/puma.rb"]
Three decisions earn their keep later. Everything mutable is under /data, so one volume is the entire tenant. Non-root user and a read-only image, so a compromised tenant cannot rewrite the app. tini as PID 1 so SIGTERM actually reaches Puma and zero-downtime restarts drain instead of kill.

What Docker does not give you

Containers share the host kernel. They are a packaging and resource-accounting boundary, not a security boundary in the way a virtual machine is. Two consequences: a kernel exploit in one tenant’s container is a host compromise, and you need to set CPU and memory limits explicitly or one tenant’s runaway import job starves the other forty. §10 covers what to do about the first; the second is two lines of compose:

yamlper-tenant limits, always
deploy:
  resources:
    limits:   { cpus: "1.0", memory: 512M }     # hard ceiling, OOM-killed above
    reservations: { memory: 128M }              # scheduler hint
03

One VPS, by hand

This is the minimal platform, and it is more platform than most people expect. One rented machine, Docker, a reverse proxy that issues certificates, one container per tenant, one directory per tenant. Coolify, Kamal and Dokku are all automation around exactly this picture; understanding it by hand is what makes their behaviour legible.

ONE VPS · 8 vCPU · 32 GB · 240 GB NVMe · UBUNTU + DOCKER :80 :443 caddy TLS · ACME · host → upstream the only public thing tenant: acme app:3000 · net: t-acme tenant: bloom app:3000 · net: t-bloom tenant: … × 60 most of them idle acme.plat.app /srv/tenants/ acme/ db, uploads, public bloom/ db, uploads, public local NVMe · one dir = one tenant cron nightly tar agent disk, cpu, mem → S3 bucket Rule: nothing but caddy has a public port. Rule: one docker network per tenant. Rule: /srv/tenants is the only thing that matters if the box dies.
The whole thing is one compose file and a cron line. Its limits are exactly the reasons the rest of this course exists: it is one machine, one failure domain, and every tenant shares its kernel.
yamldocker-compose.yml  ·  caddy plus two tenants, by hand
services:
  caddy:
    image: caddy:2
    ports: ["80:80", "443:443", "443:443/udp"]
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data              # certificates live here. back it up.
    networks: [t-acme, t-bloom]       # caddy joins every tenant network
    restart: unless-stopped

  acme:
    image: ghcr.io/you/platform@sha256:9f1c…      # by digest, never :latest
    environment:
      TENANT_ID: acme
      SECRET_KEY_BASE: ${ACME_SECRET}
      PLAN_MAX_PAGES: "200"
    volumes: ["/srv/tenants/acme:/data"]
    networks: [t-acme]                            # its own network, nothing else
    deploy: { resources: { limits: { cpus: "1.0", memory: 512M } } }
    restart: unless-stopped

  bloom:
    image: ghcr.io/you/platform@sha256:9f1c…
    environment: { TENANT_ID: bloom, SECRET_KEY_BASE: ${BLOOM_SECRET} }
    volumes: ["/srv/tenants/bloom:/data"]
    networks: [t-bloom]
    deploy: { resources: { limits: { cpus: "1.0", memory: 512M } } }
    restart: unless-stopped

networks:
  t-acme:  { internal: false }
  t-bloom: { internal: false }
volumes:
  caddy_data:
textCaddyfile  ·  a domain per tenant, certificates automatic
acme.plat.app {
    reverse_proxy acme:3000 {
        health_uri /up
        health_interval 10s
    }
    encode zstd gzip
}
bloom.plat.app, www.bloomflorists.co.ke {
    reverse_proxy bloom:3000
    encode zstd gzip
}
That is a working multi-tenant host with HTTPS. Caddy obtains and renews a certificate for every hostname in the file the first time it is asked. The thing that does not scale here is the file itself: adding a tenant means editing it and reloading, which is precisely what a control plane replaces.

Capacity: how many tenants fit?

The honest answer depends on one number you should measure, not guess: what share of tenants are active at once. For a CMS whose published output is static, the Ruby process only works when someone is editing or publishing. That share is small — single-digit percent on a normal day.

ResourcePer idle tenantPer active tenant8 vCPU / 32 GB box
Memory~120–250 MB (Puma resident set)~300–500 MB~100 idle, or ~50 active, before swap
CPU~00.2–1 core while editing/publishing~10–20 simultaneously publishing
DiskSQLite tens of MB; uploads are the variablesame240 GB ÷ (uploads quota + headroom)
ContainersDocker itself is comfortable into the low hundreds per host; the proxy and network namespaces are cheapnot the bottleneck

Memory of idle processes is the ceiling, which is why §12 introduces hibernation: stop the container of any tenant that has not touched the editor in N hours and start it on the next request. Published sites keep serving from static files regardless. With hibernation, a single box hosts many hundreds of tenants and the ceiling becomes disk and your nerves, not RAM.

04

Domains, DNS and certificates

“Connect your domain” is the feature every customer expects and the one that generates the most support tickets. It is three separate mechanisms — DNS pointing, ownership verification, and certificate issuance — and each has a right answer that platforms converged on years ago.

The records that matter

A / AAAA
Hostname → IPv4 / IPv6 address. Required at the apex (acme.co.ke) because the DNS spec forbids CNAME there. Means the customer’s DNS holds your IP, which you can then never change without asking every customer to update.
CNAME
Hostname → another hostname (www.acme.co.ke → sites.plat.app). You control what sites.plat.app resolves to forever. The right answer for subdomains and the reason platforms ask for www.
ALIAS / ANAME / flattening
Provider-specific way to get CNAME behaviour at the apex. Cloudflare, Route 53, DNSimple and most modern DNS hosts support it; many registrars’ default DNS does not. Your docs will need both paths.
TXT
Free-form. Used to prove ownership (_plat-verify.acme.co.ke = tok_8f2…) and for DNS-01 ACME challenges.
wildcard
*.plat.app → your ingress. One record, unlimited free-tier subdomains, one wildcard certificate. Set this up on day one.
SETUP, ONCE customer adds www.acme.co.ke control plane stores status: pending · token: tok_8f2 customer’s DNS provider www CNAME sites.plat.app _plat-verify TXT tok_8f2 shows poll DNS every minute TXT matches → status: verified FIRST VISIT, THEN CACHED visitor’s browser https://www.acme.co.ke caddy, your ingress SNI: www.acme.co.ke · no cert yet GET /ask?domain=www.acme.co.ke 200 if verified, 404 if not Let’s Encrypt HTTP-01 or TLS-ALPN-01, ~2 s on 200: obtain cert, store in /data, serve tenant acme :3000 or the static bucket for the site The ask endpoint is the security control: without it, anyone who points a domain at your IP gets a free certificate and hits Let’s Encrypt’s rate limits on your account.
On-demand TLS is the trick that makes “unlimited customer domains” free to operate. No certificate is requested until a real handshake arrives, and the proxy asks your control plane before it does. Coolify’s CheckDomainDnsJob is the top row; its Traefik setup takes the certificate route below instead.
textCaddyfile  ·  on-demand TLS with an ask endpoint
{
    on_demand_tls {
        ask      http://control-plane.internal:8080/tls/ask
        interval 2m
        burst    5
    }
    email ops@plat.app
}

# free-tier subdomains: one wildcard certificate, DNS-01 challenge
*.plat.app {
    tls { dns cloudflare {env.CF_API_TOKEN} }
    reverse_proxy {http.request.host.labels.2}:3000   # acme.plat.app -> acme:3000
}

# any other hostname: issue on demand, then route by looking the tenant up
https:// {
    tls { on_demand }
    reverse_proxy http://router.internal:8080 {         # a tiny service that
        header_up X-Forwarded-Host {host}               # maps host -> tenant
    }
}
rubythe ask endpoint, in the control plane
# config/routes.rb
get "/tls/ask", to: "tls#ask"

# app/controllers/tls_controller.rb
class TlsController < ApplicationController
  # Caddy calls this before requesting a certificate for an unknown host.
  # It must be fast and it must say no by default.
  def ask
    host = params[:domain].to_s.downcase.delete_suffix(".")
    ok = Domain.where(hostname: host, status: "verified")
               .joins(:tenant).merge(Tenant.active)
               .exists?
    head(ok ? :ok : :not_found)
  end
end
Two lines of SQL are the whole control. Verified, active tenant → certificate. Anything else → 404 and Caddy refuses the handshake before Let’s Encrypt is ever contacted.

The three ways to get certificates, compared

ApproachHowScales toCatch
Proxy issues per hostname (Caddy on-demand, Traefik resolvers)ACME HTTP-01 or TLS-ALPN at first request; cert stored on the nodethousands, if you gate with askCertificates live on one node — multi-node needs shared storage for them or sticky routing per hostname. Let’s Encrypt rate limits are per registered domain, so a customer with 60 subdomains can hit them.
Wildcard via DNS-01One cert for *.plat.app, renewed with a DNS API tokenunlimited subdomainsOnly for domains you control. One compromised token = one compromised wildcard.
Outsource to the edge (Cloudflare for SaaS, Vercel, Laravel Cloud)Customer CNAMEs to your fallback origin; the edge provider validates and issues, terminates TLS, then forwards to youunlimited, and you gain DDoS, WAF and cachingPer-hostname cost above the free tier; you no longer hold the certificate; TLS to your origin still needs its own cert.

Vercel and Laravel Cloud are the third row: Laravel Cloud fronts every cluster with Cloudflare Tunnels and lets Cloudflare own the customer-facing certificates. Coolify is the first row with Traefik. For your product, start with the first row on one node and move customer domains to the third row when you either add a second node or get your first DDoS — whichever comes first.

The apex problem, and the honest workaround

Customers will type acme.co.ke, not www.acme.co.ke. The apex cannot be a CNAME. Your options are: give them an A record to a stable ingress IP you promise never to change (buy a floating IP so the promise is keepable); tell them to use a DNS host that supports ALIAS/flattening; or put Cloudflare in front and let it flatten. Do all three in the docs, detect which they did, and always redirect apex → www or the reverse consistently. Coolify’s Cloudflare Domain Connect integration exists precisely to make this a button instead of a paragraph.

05

The control plane

The moment you have more than a handful of tenants, editing a Caddyfile stops being an option. What replaces it is a database of what should be true plus code that keeps making it true. That is a control plane, and Coolify — sitting in your clone — is a complete, readable example of one.

CONTROL PLANE · coolify container postgres · desired state teams → projects → environments → apps servers · destinations · domains · deployment queue id = 0 rows: the instance itself redis + horizon ApplicationDeploymentJob ServerCheckJob · backups soketi websocket → browser live status, logs livewire UI · REST API /api/v1 · MCP every mutation becomes a job, never a direct SSH from a request never on the request path of any hosted app THE CHANNEL ssh ControlMaster=auto ControlPersist, one socket per server, reused runs: docker … commands output A MANAGED SERVER · any VPS with docker dockerd · /var/run/docker.sock the only API the control plane needs on the node coolify-proxy traefik v3 :80 :443 reads container labels acme.json for certs coolify-sentinel metrics agent, --pid host cpu, mem, disk, container status → control plane app-a labels: routes app-b labels: routes helper builds, then exits destination = a docker network. proxy joins every destination network. state: named volumes on this disk. backups: tar → S3. Read it in the clone: app/Jobs/ApplicationDeploymentJob.php, app/Helpers/SshMultiplexingHelper.php, bootstrap/helpers/proxy.php, bootstrap/helpers/docker.php, app/Actions/Server/StartSentinel.php
Coolify keeps almost nothing on the managed server except Docker, a proxy and a metrics agent. All intelligence lives in the control plane and reaches the node as shell commands over a reused SSH connection. That is the least-infrastructure control plane it is possible to build, which is why it can manage a $5 VPS.

What a control plane is, minimally

1
Desired state

Tables. A tenant row has a plan, a node placement, a pinned image digest, a status. A domain row has a hostname, a verification token, a status. The UI and API only ever write rows. Nothing in a request handler touches a server.

2
Reconcilers

Jobs that read desired state, observe actual state, and issue the commands that close the gap. Idempotent, retryable, one concern each: place tenant, route domain, back up volume, rotate certificate, collect metrics.

3
A channel to nodes

How commands get there and status gets back. SSH (Coolify, Kamal), an agent that polls or is pushed to (Sentinel, flyd, kubelet), or an orchestrator’s API (Swarm, Kubernetes, Nomad).

ChannelWhat runs on the nodeGoodBadUsed by
SSH + docker CLIsshd, dockerd. Nothing of yours.Zero agent to ship or update; any VPS works in five minutes; trivially debuggable (run the same command by hand)Control plane holds root keys to everything; latency per command; a broken SSH means a blind nodeCoolify, Kamal, Dokku (locally)
AgentA daemon you wrote, outbound connection to the control planeNodes behind NAT work; push in near real time; you can enforce policy on the nodeAnother binary to version and roll out; an agent bug is a fleet bugFly.io (flyd), Kubernetes (kubelet), Coolify Sentinel for metrics only
Orchestrator APISwarm/K8s/Nomad; the orchestrator schedules for youPlacement, restarts, rolling updates and service discovery for freeYou now operate an orchestrator. For one-image-N-tenants, it solves problems you do not haveLaravel Cloud (EKS), Coolify Swarm mode

A deployment, as Coolify actually does it

Condensed from ApplicationDeploymentJob::handle() and the methods it calls. Every step is a remote shell command; every result is a log line the UI streams over Soketi.

  1. Precheck. Refuse if the queue entry was cancelled; mark IN_PROGRESS with the Horizon worker hostname; bail if $server->isFunctional() is false.
  2. Prepare the channel. Write the server’s private key to disk; SSH multiplexing reuses a control socket per server so the dozens of commands that follow don’t each pay a handshake.
  3. Learn the network. docker network inspect the destination network to build --add-host entries so the build can resolve sibling containers by name.
  4. Pick a builder. The target server by default; a dedicated build server if the app opted in and one exists in the team.
  5. Start a helper container (coollabsio/coolify-helper) with /var/run/docker.sock mounted. All git and build work happens inside it, so the host stays clean and the toolchain is versioned with Coolify, not with the server.
  6. Build per BuildPackTypes: clone, run Nixpacks/Railpack/Dockerfile/compose, tag the image with the commit, optionally push to a registry.
  7. Generate a compose file for the app with environment, volumes, health check, and — crucially — the Traefik labels that are its routing config.
  8. Rolling update. docker compose up the new container beside the old one, poll the health check, then stop the old one. If the app maps host ports or pins a container name, rolling is impossible, so it stops-then-starts and says so in the log.
  9. Post-deployment. Run the user’s post-deploy command, write configs, gracefully remove the helper, broadcast the status change.
yamlwhat Coolify writes: routing lives on the container, not in a proxy config file
services:
  acme-h3k9s:
    image: acme-h3k9s:8f2c1a9
    networks: [coolify]
    labels:
      - coolify.managed=true
      - coolify.applicationId=42
      - traefik.enable=true
      - traefik.http.routers.https-0-acme.rule=Host(`acme.example.com`) && PathPrefix(`/`)
      - traefik.http.routers.https-0-acme.entryPoints=https
      - traefik.http.routers.https-0-acme.tls=true
      - traefik.http.routers.https-0-acme.tls.certresolver=letsencrypt
      - traefik.http.routers.https-0-acme.middlewares=gzip
      - traefik.http.services.acme.loadbalancer.server.port=3000
      - traefik.http.routers.http-0-acme.rule=Host(`acme.example.com`)
      - traefik.http.routers.http-0-acme.middlewares=redirect-to-https
    healthcheck: { test: ["CMD","curl","-f","http://127.0.0.1:3000/up"], interval: 5s, retries: 10 }
Traefik watches the Docker socket and turns labels into routes as containers appear and disappear. No reload, no config file, and a container carries its own routing wherever it goes. fqdnLabelsForTraefik() and fqdnLabelsForCaddy() in bootstrap/helpers/docker.php generate these; the Caddy variant exists because Coolify supports both proxies.

The reconciliation loop, in general

rubythe shape every reconciler has
class ReconcileTenantJob
  include Sidekiq::Job
  sidekiq_options retry: 5, lock: :until_executed   # one at a time per tenant

  def perform(tenant_id)
    tenant  = Tenant.find(tenant_id)
    node    = tenant.node
    desired = tenant.desired_container_spec          # image digest, env, limits, volume
    actual  = node.inspect_container(tenant.container_name)   # nil if absent

    case
    when tenant.status == "deleted" && actual
      node.remove_container(tenant.container_name)
    when actual.nil? && tenant.should_run?
      node.run_container(desired)
    when actual && actual.image_digest != desired.image_digest
      node.rolling_replace(tenant.container_name, desired)   # start new, health, stop old
    when actual && !tenant.should_run?
      node.stop_container(tenant.container_name)             # hibernate, keep volume
    end

    tenant.update!(observed_at: Time.current, observed_digest: node.inspect_container(tenant.container_name)&.image_digest)
  end
end
Note what is absent: there is no “deploy” verb. There is only “make it match”. A deploy is a change to desired_container_spec followed by enqueueing this job; a crash recovery is the same job noticing actual.nil?. Laravel Cloud’s Go operator and Kubernetes controllers are this exact loop with a fancier hat.
06

Builds and rollouts

A general PaaS spends most of its complexity budget on builds, because it must turn arbitrary customer repositories into images. You don’t. Your tenants all run one image you built. That simplification is worth naming loudly, because it removes a build farm, a registry-per-customer, buildpack detection and a whole class of support tickets from your plan.

Where the big platforms build

PlatformBuild runsOutput goes toNotable
CoolifyOn the target server, inside a helper container with the Docker socket; or on a designated build serverLocal image, optionally pushed to a registryNixpacks/Railpack/Dockerfile/compose/static, chosen per app
Laravel CloudA Go Kubernetes operator picks a job off SQS FIFO, clones, installs, builds, bakes an imageECR, then a K8s Deployment objectOne AWS account per cluster to dodge quotas and contain blast radius
VercelIts own build infrastructure, framework-detectedStatic assets to the CDN cache; compute artifacts to the function store; config compiled into proxy metadataThe Build Output API is a published contract, so any tool can target it
KamalYour laptop or a remote builder over SSHA registry you configureNo control plane at all; the CLI is the control plane

Your build pipeline

  1. CI builds the platform image on every tag: multi-stage Dockerfile from §02, pushed to GHCR (or ECR) with both a version tag and, more importantly, a digest.
  2. A release row in the control plane records the digest, the changelog, and whether it carries a schema migration.
  3. Tenants pin a digest. Rolling out is changing the pinned digest on a set of tenant rows and letting the reconciler from §05 do the rest.
  4. Cohorts. Your own tenants first, then 1%, then 10%, then everyone, each step gated on the previous cohort’s error rate and health-check pass rate. The plugin course’s rollout advice applies verbatim.

The per-tenant rolling update, with SQLite in the picture

Coolify’s rolling_update() starts the new container, waits for health, then stops the old one. Kamal’s kamal-proxy does the same and only switches traffic once the new container answers its health check. With a stateless app that is the whole story; with SQLite there are two extra rules:

rubyrolling replace on one node, over the Docker API
def rolling_replace(name, spec)
  new_name = "#{name}-#{Time.now.to_i}"
  run_container(spec.merge(name: new_name))              # same volume, same network
  wait_healthy!(new_name, timeout: 60)                    # polls the HEALTHCHECK state
  proxy.point(spec.hostnames, to: "#{new_name}:3000")     # caddy admin API / label swap
  stop_container(name, grace: 20)                         # SIGTERM, then SIGKILL
  rename_container(new_name, name)
rescue HealthTimeout
  remove_container(new_name)                              # old one never stopped
  raise
end
The health wait is the whole trick. Without it you have a restart, not a rollout, and every deploy is a few seconds of 502 for anyone mid-edit.
Rollback is a digest, not a ceremony

Because tenants pin an image digest, rollback is setting the previous digest and re-running the reconciler. Keep the last five release images in the registry, keep migrations additive so the old code still runs on the new schema, and rollback becomes a five-second operation you can trust at 2am. Platforms that build a “rollback” feature are usually compensating for not having done this.

07

State: SQLite, uploads, and object storage

Compute is fungible; state is where hosting gets hard. Your product has three kinds of it per tenant, and each wants a different home. Getting this split right is what makes tenants movable, backups trivial, and published sites survive anything that happens to the Ruby process.

TENANT NODE · LOCAL NVMe ruby app (editor + publisher) hibernates when idle db.sqlite3 WAL mode · tens of MB uploads/ the variable one public/ (baked HTML, CSS, assets) regenerated on publish; disposable litestream sidecar: tails the WAL S3-COMPATIBLE BUCKET tenants/acme/db/ WAL segments, ~1 s behind tenants/acme/uploads/ source of truth after first sync sites/acme/ the published site, versioned snapshots/acme/2026-09-07.tar.zst whole /data, nightly, 30 days continuous replication sync on write push on publish nightly tar of /data (Coolify VolumeBackupJob does exactly this) CDN acme.co.ke → sites/acme/ cached at the edge visitors never touch the container Restore anywhere: pull snapshot → litestream restore to catch up → start container on the new node → flip routing. Minutes, scripted.
The design does one thing above all: it makes the container and its node disposable. Every byte a tenant cares about is also in the bucket within seconds, and the published site is served from the bucket, so a dead node costs editing time, not uptime.

SQLite: three ways to make one file survivable

ApproachHowData loss windowCost per tenantVerdict for you
Snapshot & uploadCron: sqlite3 .backup or tar of the volume → S3. Coolify’s VolumeBackupJob streams a tar straight to S3 from a helper container.up to the interval (hours)~0do this regardless — it is the restore you will actually use
LitestreamA sidecar tails the WAL and ships segments to S3 continuously. v0.5 (Oct 2025) rewrote the storage format and added a VFS for read replicas.~1 secondtiny CPU, one processyes, on paid plans — near-zero RPO for the price of a sidecar
Live replicas (LiteFS, Turso/libSQL)FUSE- or VFS-level replication to other nodes; writes forwarded to a primary~0a second node per tenant, and complexitynot yet — solves multi-region reads you do not have
yamllitestream.yml  ·  one sidecar per tenant container, same volume
dbs:
  - path: /data/db/production.sqlite3
    replicas:
      - type: s3
        bucket: plat-tenants
        path: tenants/${TENANT_ID}/db
        endpoint: https://fsn1.your-objectstorage.com     # any S3-compatible
        sync-interval: 1s
        retention: 72h
        snapshot-interval: 6h
Restore is litestream restore -o /data/db/production.sqlite3 s3://plat-tenants/tenants/acme/db. Rehearse it monthly, on a real tenant, on a scratch node, with a stopwatch. A backup nobody has restored is a hypothesis.

Uploads: local disk is a cache, the bucket is the truth

Media is the state that grows without bound and the one that makes tenants heavy to move. The pattern that scales: the app writes uploads to the bucket (directly, or to local disk then a background sync), serves them from the bucket through the CDN, and treats the local uploads/ as a warm cache that can be dropped. Rails ActiveStorage with an S3 service does this natively; give each tenant credentials scoped to its own prefix so a bug in one tenant cannot read another’s files.

Published output: the reason visitors never wake the Ruby process

This is the load-bearing decision for both cost and reliability. Instatic already bakes pages to disk on publish. Push that directory to sites/<tenant>/ in the bucket on every publish, and point the CDN at the bucket for the tenant’s hostnames. Then:

Choosing the bucket

OptionTypeWhyWatch
Cloudflare R2HostedZero egress fees, and a CDN and custom domains built in — the natural home for published sitesLatency to the bucket from a non-Cloudflare node
Hetzner Object StorageHostedSame datacentre as Hetzner nodes, cheap, S3-compatibleEgress to the public internet is metered
Backblaze B2HostedCheapest per GB stored; free egress to CloudflareFine for backups, less so as a hot origin
GarageSelf-hostedTiny footprint, geo-distributed by design, AGPLNo versioning or object lock yet
SeaweedFSSelf-hostedMature since 2012, scales large, Apache 2.0Master/volume split is real ops work
RustFSSelf-hostedDrop-in MinIO replacement, Apache 2.0Young; gaps in object lock and encryption modes
MinIO is no longer the default

The MinIO community repository entered maintenance mode in December 2025 and was archived read-only in April 2026 as the company moved to its enterprise product. Existing installs keep running, but do not start a new platform on it. Coolify’s own dev stack still lists a minio service for local testing, which is fine for that purpose and not a production recommendation. Start hosted (R2 or Hetzner Object Storage); self-host with Garage or SeaweedFS only when the bill says to.

08

One big VPS, or many?

Your actual question. The answer is not a machine count; it is a property of the control plane. Make a tenant’s placement a column, and the number of machines stops being an architectural decision and becomes a capacity one. Then start with one.

STAGE 1 · ONE BOX control plane (also here, for now) caddy tenants 1 … 400 placement = "node-a" on every row /srv/tenants on local NVMe S3: every tenant, always Failure domain: everything. Mitigation: the bucket, a tested restore, and a spare box you can rent in 90 s. STAGE 2 · CELLS · SAME CONTROL PLANE, MORE ROWS control plane · its own small box now placements: acme→A · bloom→A · corex→B · dune→C … ingress: caddy per node, or one edge that knows the placement table node A tenants 1–400 own caddy, own volumes kernel update = 400 sites’ editors down 2 min node B tenants 401–800 own caddy, own volumes node C enterprise tenants, 20 bigger box, fewer neighbours MOVING ONE TENANT A → B 1 stop on A · 2 final snapshot → S3 · 3 restore on B · 4 start on B · 5 placement := B · 6 proxy flips · 7 delete from A after 24 h Editor is down for the copy time — seconds for most tenants, minutes for media-heavy ones. The published site never blinks. Nothing in stage 2 that did not exist in stage 1 except the node column and this one job.
Cells, not clusters. Each node is a complete, independent unit; the control plane is the only shared thing. Laravel Cloud’s one-AWS-account-per-EKS-cluster is the same idea at a much larger scale, and for the same reasons: quotas and blast radius.

The trade-offs, without romance

One big box
What you get

Lowest cost per tenant by a wide margin, especially on dedicated hardware. One thing to patch, monitor and reason about. No network between components, so everything is fast and simple. Zero distributed-systems problems.

One big box
What you pay

Every failure is total: a kernel panic, a full disk, a bad Docker upgrade, a reboot for a security patch — all tenants’ editors go dark together. A noisy tenant is everyone’s noise. There is a ceiling, and you will find it on a day you did not choose.

Many boxes
What you get

Failures and maintenance windows affect one cell. Tenants can be placed by plan, size or region. You can drain a node and retire it. Capacity is a purchase, not a migration.

Many boxes
What you pay

Placement logic, a migration job, per-node proxies or a smarter edge, more surface to monitor. Not a lot — but it is code you must write and test before the day you need it.

The concrete recommendation

  1. Start on one box, but write the placement column on day one. Every tenant row says node: "a" even when there is only a. Every job looks up the node from the row rather than assuming localhost. This costs nothing now and is the difference between “add a node” being an afternoon or a rewrite.
  2. Get off-box state working before your tenth customer. Bucket, Litestream, nightly snapshots, and a restore script you have run. With that in place, a one-box failure is an outage of the editor measured in the time to rent a replacement and restore — tens of minutes — not a data-loss event.
  3. Move the control plane to its own tiny box early. When the tenant node is unhealthy you need the thing that fixes it to be healthy. A $5 VPS is enough; it is a Rails app and Postgres.
  4. Add cells when a metric says to: sustained memory above 70%, disk above 60%, or a plan tier that wants fewer neighbours. Not before. The second node is the expensive one, because it forces the migration job to exist; the tenth is free.
  5. Do not reach for Kubernetes, Swarm or Nomad for this shape. They schedule heterogeneous workloads across a fleet. You have one image, hundreds of identical, single-node, disk-pinned instances. The placement table plus a reconciler is your scheduler, and it fits in a few hundred lines you fully understand.

Dedicated versus cloud VPS

The old rule — cloud VPS for flexibility, dedicated for price per core — has sharpened. Hetzner raised cloud server prices substantially with effect from 15 June 2026 (reported multiples of two to three times on the larger CCX and CPX tiers), while dedicated lines remain the cheapest compute per core available to a small operator. For a workload that is mostly idle processes and local disk, a dedicated box with NVMe is close to ideal. Its weaknesses are exactly the ones this course has already designed around: no snapshots (you have the bucket), no live migration (you have the placement job), and a hardware failure means a reinstall (you have a scripted node bootstrap, §11). Keep one cloud VPS as the control plane and one as a warm spare, put tenants on metal, and you have most of the benefits of both.

Worked exampleAssumptionResult
Tenants500 sites, 5% active at peak, hibernation on~25 running containers, ~475 stopped
Memory25 × ~400 MB + proxy + OS~12 GB in use of 64 GB
Disk500 × (50 MB db + 2 GB uploads cap)~1 TB if everyone fills their quota; NVMe plus bucket offload keeps local far lower
Nodesone dedicated box for tenants, one small VPS for control, one sparefits comfortably with headroom for a 2× spike
Billmetal + two small VPS + bucket + CDNlow hundreds of euros a month — check current prices, they moved this year
09

How the big ones are built

Four platforms, four very different amounts of money, one shape. Read them for what they chose to own — that is the real architectural decision — and notice how much each one hands to somebody else.

Vercel
Owns the edge and the build contract; rents the compute

DNS resolves to a Vercel anycast IP. A global load balancer picks a PoP; the request rides a private backbone; a TLS terminator and always-on DDoS system sit in front; then an application-aware proxy consults a globally replicated metadata store — compiled from your vercel.json and framework config at build time — to decide static file, cached response, middleware, or function.

Builds separate output into static assets (to the CDN) and compute artifacts (to a function store). Deployments are immutable; a domain is an alias pointing at one. Functions run on Fluid compute, which lets one instance serve many concurrent requests. The published Build Output API means the platform is, at heart, a contract plus an edge.

Laravel Cloud
Owns the operator; rents everything else from AWS and Cloudflare

Kubernetes on EKS, with one AWS account per cluster plus ancillary accounts per region for networking, logs and builds — explicitly to avoid AWS quotas and limit blast radius. A single shared VPC via Resource Access Manager, IPv6 to escape address exhaustion.

Cloudflare Tunnels connect Cloudflare’s edge into each cluster, so Cloudflare owns DDoS, caching and customer-facing TLS; an Nginx router inside forwards to pods. A deploy is an SQS FIFO message picked up by a Go operator that clones, builds, bakes an image to ECR and applies a Deployment. Hibernation scales idle apps to zero on an HTTP idle timer and eats the cold start. Databases are EBS-backed pods run by a custom operator. They report signup-to-live under 30 seconds.

Fly.io
Owns the metal, the VMM, the proxy and the network

Apps run as Firecracker microVMs on Fly’s own physical servers (8–32 cores, 32–256 GB each) — hardware virtualisation is what lets them safely mix customers on one box. IP ranges are announced by BGP anycast from every region; fly-proxy, in Rust, on every host, accepts the connection, terminates TLS, finds the app, and if the nearest VM is elsewhere, tunnels the connection there over WireGuard.

This is the most infrastructure of the four and it exists because Fly sells “run anything, near users”, which nobody else’s primitives would give them.

Coolify & Kamal
Own the control plane; rent nothing — you bring the servers

Coolify: a Laravel control plane that reaches your servers over multiplexed SSH, runs Docker commands, puts Traefik (or Caddy) on each server to route by container label, runs a Sentinel agent for metrics, backs volumes and databases up to any S3, and can provision Hetzner servers from an API token. Any Linux box with Docker becomes a node in minutes.

Kamal is the same picture minus the server: your laptop is the control plane, kamal-proxy on each host does health-checked traffic switching and automatic TLS, and state is in a YAML file in the repo. It is what 37signals uses to run Basecamp and HEY on their own hardware after leaving AWS.

VercelLaravel CloudFly.ioCoolifyKamalYou, per this course
Isolation unitFunction instancePod (container)Firecracker microVMContainerContainerContainer per tenant
Who owns the metalAWS + own edgeAWSFlyYouYouHetzner/OVH/…, you rent
Edge / TLSOwn anycast + TLS terminatorCloudflare TunnelsOwn anycast + fly-proxyTraefik or Caddy per serverkamal-proxy per hostCaddy per node, Cloudflare in front later
Routing config lives inReplicated metadata storeK8s objects + NginxCorrosion-replicated stateContainer labelskamal-proxy stateControl-plane DB, pushed to Caddy
Control → node channelInternalK8s API via operatorflyd agentSSHSSHSSH first, agent if NAT forces it
BuildsOwn build infraOperator → ECRRemote buildersOn node or build serverLocal or remote builderCI → GHCR, once per release
Scale to zeroNativeHibernation on idleAutostop / autostartNoNoHibernate idle editors; static sites always up
StateExternalEBS-backed DB podsVolumes + LiteFSVolumes + S3 backupsVolumesVolume + Litestream + bucket
The one pattern in all six columns

Desired state in a store → a reconciler → a channel to the node → a proxy that learns routes from state, not from a file. Vercel’s metadata service, Laravel Cloud’s operator, Fly’s corrosion, Coolify’s labels and Kamal’s proxy state are the same box drawn at different sizes. When you build yours, you are choosing sizes, not inventing a shape.

10

Isolation and security

You are running other people’s data, and possibly other people’s code, on one kernel. The question to answer before choosing any technology is: can a tenant execute code I did not write? The answer moves you between two very different worlds.

ThreatIf tenants run only your imageIf tenants can run their own code (plugins, custom Ruby, uploaded templates with logic)
Tenant reads another tenant’s filesBug in your app. Fix: per-tenant volume, per-tenant bucket prefix, non-root user, read-only image.Same, plus the code can try. Container boundaries hold against this if configured.
Tenant reaches another tenant’s processPer-tenant Docker network; no inter-tenant routes.Same, plus egress control: the code will try to scan the host network.
Tenant escapes to the host kernelRequires a kernel exploit through your app’s attack surface. Keep the kernel patched.real threat. Containers are not a security boundary against hostile code. You need gVisor, Kata, or Firecracker — a VM boundary.
Tenant exhausts the boxcgroup limits on CPU, memory, pids; disk quotas per volume.Same, plus fuel/timeouts inside any sandboxed runtime.
Tenant hosts abuse (phishing, malware)Content policy, reporting, and a suspend button that works in seconds.Same, harder to detect.

Instatic’s plugin system runs plugins in QuickJS compiled to WASM with explicit permissions — the same design the plugin course covered for Figma. If your Ruby variant offers plugins, that is the right shape: sandbox the plugin inside your process, never let the tenant run a raw Ruby process. That keeps you in the left column, where Docker is enough.

The container hardening checklist, left column

yamlper-tenant service, hardened
acme:
  image: ghcr.io/you/platform@sha256:9f1c…
  user: "10001:10001"
  read_only: true                      # image is immutable at runtime
  tmpfs: ["/tmp:size=64m", "/app/tmp:size=64m"]
  volumes: ["/srv/tenants/acme:/data"] # the only writable path
  cap_drop: ["ALL"]
  security_opt: ["no-new-privileges:true"]
  pids_limit: 256
  networks: [t-acme]                   # no shared network, no docker.sock, ever
  deploy: { resources: { limits: { cpus: "1.0", memory: 512M } } }
  logging: { driver: json-file, options: { max-size: "10m", max-file: "3" } }
Six lines separate “a container” from “a container you would put a stranger’s data in”. None of them cost performance. The log rotation line is not a security control; it is the line that stops a chatty tenant filling the disk, which is its own kind of denial of service.
A note on Coolify’s own posture

Coolify SSHes into servers as a user with Docker access and mounts /var/run/docker.sock into its helper and proxy containers. That is entirely appropriate for its purpose — you deploying your applications onto your servers — and would be inappropriate for hosting hostile tenants, where a socket mount is root on the host. Copy Coolify’s control-plane design freely; do not copy its trust model for a multi-tenant product.

The rest

11

Operating it

The features people admire in a platform are mostly operations made visible. This is the runbook, ordered by how often each item pages you.

Thing that will happenHow oftenWhat you need to have built already
Disk fills up — a tenant uploads 30 GB of video, Docker images pile up, logs growConstantly, until you fix itPer-tenant quota enforced in the app; log rotation; a nightly docker system prune (Coolify has DockerCleanupJob with a disk threshold); disk alert at 70%; uploads offloaded to the bucket
A tenant is suspended — unpaid, abuse, legalWeeklyA status flag the reconciler honours: stop container, keep volume and backups for N days, serve a holding page from the proxy. One click, reversible
Kernel or Docker update needs a rebootMonthlyDrain: mark node draining, reconciler stops placing there; announce; reboot in the quietest hour; published sites unaffected because they are static. With cells, one cell at a time
Node diesYearly, on a bad yearScripted node bootstrap (cloud-init or a shell script; Coolify stores CloudInitScript rows for this), the bucket, and a “restore all tenants placed on node X onto node Y” job you have rehearsed
Certificate renewal fails — customer changed DNS, CAA record, rate limitWeekly across a fleetExpiry monitoring (Coolify: SslExpirationNotification), a re-verify job that flips the domain back to pending with a customer email, and no hard dependency on any single hostname
Abuse report — phishing page on *.plat.appWeekly once you are visibleTakedown flow: suspend, snapshot for evidence, notify. A wildcard subdomain is a phishing magnet; require email verification before a site is public
“My site is slow”WeeklyPer-tenant metrics: CPU, memory, request latency, container restarts. Coolify’s Sentinel plus its HasMetrics trait is the shape; cAdvisor + node-exporter + Grafana is the standard stack
Billing disputeMonthlyMeters: storage GB-days, bandwidth GB, instance-hours, publish count — written by the reconciler and the CDN logs, not estimated
Restore drillMonthly, by policyA script, a scratch node, and a calendar entry. The only proof a backup exists
bashnode bootstrap  ·  what “add a node” runs, idempotently
#!/usr/bin/env bash
set -euo pipefail
# 1. base
apt-get update && apt-get install -y ca-certificates curl ufw unattended-upgrades
curl -fsSL https://get.docker.com | sh
# 2. firewall: only ssh, http, https. control plane IP only for ssh.
ufw default deny incoming; ufw allow from "$CONTROL_PLANE_IP" to any port 22
ufw allow 80/tcp; ufw allow 443/tcp; ufw allow 443/udp; ufw --force enable
# 3. layout
mkdir -p /srv/tenants /srv/caddy
# 4. docker daemon defaults: log rotation, live-restore so a dockerd restart
#    doesn't kill every tenant
cat > /etc/docker/daemon.json <<'EOF'
{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" },
  "live-restore": true, "default-address-pools": [{ "base": "10.200.0.0/16", "size": 28 }] }
EOF
systemctl restart docker
# 5. node agent or ssh key for the control plane; caddy with on-demand TLS
docker compose -f /srv/caddy/docker-compose.yml up -d
# 6. register with the control plane
curl -fsS -X POST "$CONTROL_PLANE/nodes" -H "Authorization: Bearer $NODE_TOKEN" \
     -d "hostname=$(hostname)&ip=$(curl -s https://api.ipify.org)&capacity_gb=$(df --output=avail -BG /srv | tail -1)"
Two details that matter more than they look. default-address-pools with a /28 per network is what lets you create hundreds of per-tenant networks without Docker running out of subnets. live-restore is what lets you upgrade Docker without stopping every tenant.
12

The architecture, concretely

Everything above, assembled into one design for the Instatic-with-Ruby product on rented machines. It is deliberately boring: a Rails control plane, Docker nodes, Caddy, a bucket, a CDN. The interesting decisions are the two that make it cheap — static sites off the container, and hibernating editors.

CI: git tag → build → GHCR ghcr.io/you/platform@sha256:… editors → acme.plat.app / admin the only traffic that reaches a node site visitors → www.acme.co.ke never reach a node CONTROL PLANE · small VPS rails · postgres · sidekiq desired state tenants · placements · domains releases · nodes · meters · audit reconcilers place · route · verify DNS · back up hibernate · migrate · roll out · meter endpoints for nodes /tls/ask · /wake · /nodes · /metrics billing · abuse · dashboard · API down? tenants keep serving. release row: digest NODES · DEDICATED, NVMe, DOCKER cell A caddy: on-demand TLS · wake gate :80 :443, the only public ports tenants × 400 ~20 running ~380 hibernating + litestream sidecars /srv/tenants db · uploads cache public/ before push one dir = one tenant cell B · when a metric says so identical. bootstrapped by script. tenants land here by placement row. + one warm spare, powered off, for the day cell A dies ssh / agent ask, wake pull by digest BUCKET · R2 / HETZNER / B2 tenants/<id>/db  ·  litestream tenants/<id>/uploads  ·  truth sites/<id>/v<n>/  ·  published snapshots/<id>/  ·  nightly versioned bucket, write-only creds on nodes the tenant, in full, at all times replicate publish CDN · custom hostnames www.acme.co.ke → sites/acme/v42/ cached at the edge, immutable per version (via CDN) Failure of any single box: control plane down → nothing changes, everything serves. A node down → ~400 editors offline until restore-to-spare, sites unaffected. Bucket down → editing works, publishing queues.
Two ideas carry the economics. Visitors are served from the bucket through the CDN, so the expensive part — a Ruby process — runs only while someone edits. And because every tenant is fully in the bucket, nodes are cattle: a placement row and a restore script are the whole recovery plan.

The tables

TableKey columnsWritten by
nodeshostname, ip, ssh/agent credential ref, capacity, status (active / draining / dead), regionbootstrap script, ops
releasesversion, image digest, has_migration, rolling_safe, created_atCI
tenantsslug, plan, status (provisioning / running / hibernated / suspended / deleted), node_id, release_id, secret refs, last_editor_activity_at, quota_gbsignup, reconcilers, billing
domainstenant_id, hostname, kind (platform / custom), verification_token, status, verified_at, last_checked_atdashboard, DNS checker
publishestenant_id, version, bucket_prefix, size_bytes, is_livethe app, on publish
meterstenant_id, day, storage_gb, egress_gb, editor_hours, publishesreconcilers, CDN logs
audit_eventsactor, tenant_id, node_id, command, result, ateverything that touches a node

Tenant lifecycle

TransitionWhat the reconciler doesVisible to the customer
signup → provisioningPick a node (least-loaded active node with disk), create /srv/tenants/<slug>, generate secrets, create bucket prefix + scoped credentials, start container from the current release, create <slug>.plat.app domain row“Setting up your site…” for ~5 s
provisioningrunningHealth check passed; DNS wildcard already covers the subdomain; certificate is the wildcardEditor opens
runninghibernatedNo editor request for N hours: stop container, keep volume, keep sidecar snapshotNothing — the site is static
hibernatedrunningCaddy’s wake gate sees a request for a stopped tenant: docker start, wait for health, proxyA 3–8 s first load with a “waking up” page
suspendedStop container, replace routes with a holding page, freeze publishing, keep everythingEditor locked, site still up (or a notice, your policy)
deletedStop, final snapshot, delete volume and CDN mapping, retain bucket prefix 30 days, then purgeGone, with a grace window
migrate A → BThe seven steps from §08, as one job with a resumable step counterEditor offline for the copy; site untouched
rubyapp/jobs/provision_tenant_job.rb
class ProvisionTenantJob
  include Sidekiq::Job
  sidekiq_options retry: 3, lock: :until_executed

  def perform(tenant_id)
    tenant = Tenant.find(tenant_id)
    return unless tenant.status == "provisioning"

    tenant.with_lock do
      tenant.node    ||= Node.active.with_free_capacity.order(:tenant_count).first!
      tenant.release ||= Release.current
      tenant.secret_key_base ||= SecureRandom.hex(64)
      tenant.save!
    end

    node = tenant.node
    ObjectStore.ensure_prefix!(tenant)            # tenants/<slug>/ + scoped creds
    node.exec!("install -d -o 10001 -g 10001 -m 0750 /srv/tenants/#{tenant.slug}")

    node.run_container!(
      name:    tenant.container_name,
      image:   tenant.release.image_ref,          # "ghcr.io/you/platform@sha256:…"
      env:     tenant.runtime_env,                # TENANT_ID, SECRET_KEY_BASE, S3_*
      volume:  "/srv/tenants/#{tenant.slug}:/data",
      network: "t-#{tenant.slug}",
      limits:  tenant.plan.limits,                # cpus, memory, pids
      hardened: true,
    )
    node.run_sidecar!(:litestream, tenant)
    node.wait_healthy!(tenant.container_name, timeout: 60)

    Domain.create!(tenant: tenant, hostname: "#{tenant.slug}.plat.app",
                   kind: "platform", status: "verified")   # wildcard covers it
    node.proxy.upsert_route!(tenant)              # caddy admin API, host -> upstream

    tenant.update!(status: "running", provisioned_at: Time.current)
    AuditEvent.record!(tenant:, node:, command: "provision", result: "ok")
  rescue => e
    tenant.update!(status: "provisioning", last_error: e.message)   # retry-safe
    raise
  end
end
Every step is idempotent so the job can be retried from the top after any failure. The node abstraction (exec!, run_container!) hides whether the channel is SSH or an agent, which is what lets you swap that decision later without touching a reconciler.

Hibernation: the wake gate

Editor hostnames route through a small gate instead of straight to the container. It is a platform component you trust, so it may talk to the Docker socket. It does one thing: if the tenant’s container is stopped, start it, wait for health, then proxy. The control plane separately stops containers idle for N hours.

rubygate.rb  ·  a rack app in front of every editor
require "rack"; require "docker"; require "net/http"

class WakeGate
  WAKING = Rack::Files.new("holding").method(:call)   # a "waking up…" page

  def call(env)
    host   = env["HTTP_HOST"].split(":").first
    tenant = Router.tenant_for(host) or return [404, {}, ["no such site"]]
    c      = Docker::Container.get(tenant.container_name) rescue nil
    return [503, {}, ["provisioning"]] unless c

    unless c.info["State"]["Running"]
      c.start                                        # docker start, ~1 s
      ControlPlane.notify_woke(tenant.id)            # resets the idle clock
      return [503, {"retry-after" => "3", "refresh" => "3"}, [holding_page]]
    end
    return [503, {"refresh" => "2"}, [holding_page]] unless healthy?(c)

    Proxy.forward(env, to: "#{tenant.container_name}:3000")   # normal case
  end
end
The holding page refreshes itself; by the second or third refresh Puma is up and the request flows. Laravel Cloud’s hibernation and Fly’s autostart are this mechanism with a bigger budget. Once you have it, a 64 GB box hosting a thousand mostly-idle tenants is not a stretch.

What the Ruby app itself must do differently

13

Build order, pitfalls, glossary

In this order, each step shippable

  1. The image and the volume contract. Multi-stage Dockerfile, everything mutable under /data, health endpoint, WAL mode. Run it by hand on your laptop with a bind mount. This is a week and it is the foundation of everything.
  2. One node, by hand. Caddy with the wildcard certificate, five tenants in a compose file, a cron tar to a bucket. Onboard your first real users on this. Yes, really.
  3. The control plane, minimal. Rails, the seven tables, ProvisionTenantJob, ReconcileTenantJob, SSH channel. Replace the hand-edited compose file with the reconciler. Nothing the customer sees changes.
  4. Static publishing to the bucket + CDN. The moment visitors stop hitting Ruby, your cost curve bends and your uptime story becomes real.
  5. Litestream sidecars and the restore drill. Rehearse a restore to a scratch node with a stopwatch. Write the number down.
  6. Custom domains with the ask endpoint and the DNS checker.
  7. Hibernation. The wake gate and the idle job. Watch memory fall.
  8. Cohort rollouts from release rows.
  9. The second node and the migration job — only when a metric asks. Then Cloudflare in front of the editors, and its custom-hostname product in front of the sites, when you want the DDoS story or the second region.
PitfallHow it presentsFix
SQLite on a network filesystemMysterious corruption under loadLocal disk only. Replicate with Litestream; never share the file
Serving published sites from the containerOne viral page takes the editor of 400 other tenants downBucket + CDN. The Ruby process is for editing
Deploying by tag“It changed and nobody deployed anything”Pin digests per tenant; rollback is a digest
Placement assumed to be localhostAdding a second node is a rewriteNode column on day one, even with one node
Unrestricted on-demand TLSLet’s Encrypt rate-limits your whole platformThe ask endpoint; verified domains only
Docker socket in a tenant containerRoot on the host for the price of an uploadNever. Gate and proxy are trusted platform components; tenants are not
No log rotation, no pruneDisk full on a Sundaydaemon.json log limits, nightly prune, quota per tenant, alert at 70%
Backups nobody restoredA backup that is a hypothesisMonthly drill on a scratch node, timed
Control plane on the tenant nodeThe thing that fixes the node is on the broken nodeA separate small VPS from the start
Reaching for KubernetesMonths on the platform, no customersA placement table and a reconciler are the scheduler for one-image-N-tenants

Glossary

Anycast
Announcing one IP from many locations so the network routes each client to the nearest. Vercel and Fly’s front door; not something a small operator needs.
Cell
A complete, independent unit of the data plane — a node with its own proxy and tenants — sharing only the control plane with other cells.
Control plane
The software that holds desired state and makes reality match it. Never on the request path.
Data plane
Where tenant workloads and their state actually run and are served from.
Digest
The content hash of an image. The only trustworthy way to name what you deployed.
Hibernation
Stopping an idle tenant’s process while keeping its state, and restarting it on demand.
On-demand TLS
Issuing a certificate at the first TLS handshake for a hostname, after asking the control plane whether to.
Placement
The row that says which node a tenant runs on. The column that makes scaling out a capacity decision.
Reconciler
An idempotent job that reads desired state, observes actual state, and issues the commands that close the gap.
Rolling replace
Start the new container, wait for health, switch traffic, stop the old one. Zero-downtime deploy.
Volume
A host directory mounted into a container that outlives the container. Where all tenant state lives on the node.
WAL
SQLite’s write-ahead log mode. Required for concurrent readers, for Litestream, and for the seconds of overlap in a rolling replace.