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.
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.
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:
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 model | Isolation | Density (tenants per box) | Per-tenant backup | Blast radius of one bug | Who does this |
|---|---|---|---|---|---|
| Instance per tenant this course | strong — process, files, DB all separate | tens active, hundreds idle | Copy two paths. Trivial. | One tenant | Instatic, Ghost(Pro), WordPress hosts, Coolify apps |
| Multi-tenant app | logical — one row filter away from a leak | thousands | Hard: one big DB, per-tenant export is a feature you build | Everyone | Shopify, Notion, most SaaS |
| Hybrid — shared editor, per-tenant data stores | mixed | high | Per-tenant DB file, shared code | Everyone for code, one for data | Turso-style DB-per-tenant, Fly’s sprites |
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.
SIGTERM, and never assume it is the only copy in the world.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.200 when the app can serve. The proxy and the deploy process both depend on it; without it there is no zero-downtime anything.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.
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.
sha256:…); tags like :v1.4 are mutable pointers to a digest. Deploy by digest, never by tag./var/lib/docker/volumes or bind mounts to a path you choose.| Method | What it is | Who controls the result | Coolify enum |
|---|---|---|---|
| Dockerfile | Explicit recipe you write | You, fully | dockerfile |
| Buildpacks (Nixpacks, Railpack, CNB) | Detect the language, generate the recipe | The buildpack; you tweak with config | nixpacks, railpack |
| Compose | Several images plus their wiring | You | dockercompose |
| Static | Build output copied into an nginx/Caddy image | The platform | static |
| Prebuilt image | No build; pull from a registry | Whoever 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.
# ---- 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"]
/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.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:
deploy:
resources:
limits: { cpus: "1.0", memory: 512M } # hard ceiling, OOM-killed above
reservations: { memory: 128M } # scheduler hint
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.
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:
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
}
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.
| Resource | Per idle tenant | Per active tenant | 8 vCPU / 32 GB box |
|---|---|---|---|
| Memory | ~120–250 MB (Puma resident set) | ~300–500 MB | ~100 idle, or ~50 active, before swap |
| CPU | ~0 | 0.2–1 core while editing/publishing | ~10–20 simultaneously publishing |
| Disk | SQLite tens of MB; uploads are the variable | same | 240 GB ÷ (uploads quota + headroom) |
| Containers | Docker itself is comfortable into the low hundreds per host; the proxy and network namespaces are cheap | not 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.
“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.
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.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._plat-verify.acme.co.ke = tok_8f2…) and for DNS-01 ACME challenges.*.plat.app → your ingress. One record, unlimited free-tier subdomains, one wildcard certificate. Set this up on day one.CheckDomainDnsJob is the top row; its Traefik setup takes the certificate route below instead.{
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
}
}
# 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
| Approach | How | Scales to | Catch |
|---|---|---|---|
| Proxy issues per hostname (Caddy on-demand, Traefik resolvers) | ACME HTTP-01 or TLS-ALPN at first request; cert stored on the node | thousands, if you gate with ask | Certificates 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-01 | One cert for *.plat.app, renewed with a DNS API token | unlimited subdomains | Only 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 you | unlimited, and you gain DDoS, WAF and caching | Per-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.
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.
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.
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.
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.
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).
| Channel | What runs on the node | Good | Bad | Used by |
|---|---|---|---|---|
| SSH + docker CLI | sshd, 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 node | Coolify, Kamal, Dokku (locally) |
| Agent | A daemon you wrote, outbound connection to the control plane | Nodes behind NAT work; push in near real time; you can enforce policy on the node | Another binary to version and roll out; an agent bug is a fleet bug | Fly.io (flyd), Kubernetes (kubelet), Coolify Sentinel for metrics only |
| Orchestrator API | Swarm/K8s/Nomad; the orchestrator schedules for you | Placement, restarts, rolling updates and service discovery for free | You now operate an orchestrator. For one-image-N-tenants, it solves problems you do not have | Laravel Cloud (EKS), Coolify Swarm mode |
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.
IN_PROGRESS with the Horizon worker hostname; bail if $server->isFunctional() is false.docker network inspect the destination network to build --add-host entries so the build can resolve sibling containers by name.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.BuildPackTypes: clone, run Nixpacks/Railpack/Dockerfile/compose, tag the image with the commit, optionally push to a registry.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.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 }
fqdnLabelsForTraefik() and fqdnLabelsForCaddy() in bootstrap/helpers/docker.php generate these; the Caddy variant exists because Coolify supports both proxies.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
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.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.
| Platform | Build runs | Output goes to | Notable |
|---|---|---|---|
| Coolify | On the target server, inside a helper container with the Docker socket; or on a designated build server | Local image, optionally pushed to a registry | Nixpacks/Railpack/Dockerfile/compose/static, chosen per app |
| Laravel Cloud | A Go Kubernetes operator picks a job off SQS FIFO, clones, installs, builds, bakes an image | ECR, then a K8s Deployment object | One AWS account per cluster to dodge quotas and contain blast radius |
| Vercel | Its own build infrastructure, framework-detected | Static assets to the CDN cache; compute artifacts to the function store; config compiled into proxy metadata | The Build Output API is a published contract, so any tool can target it |
| Kamal | Your laptop or a remote builder over SSH | A registry you configure | No control plane at all; the CLI is the control plane |
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:
PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000) and catastrophic otherwise. Set it in the image, not in a migration someone can forget.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
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.
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.
| Approach | How | Data loss window | Cost per tenant | Verdict for you |
|---|---|---|---|---|
| Snapshot & upload | Cron: 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) | ~0 | do this regardless — it is the restore you will actually use |
| Litestream | A 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 second | tiny CPU, one process | yes, 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 | ~0 | a second node per tenant, and complexity | not yet — solves multi-region reads you do not have |
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
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.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.
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:
sites/acme/v42/, flip a pointer, keep the previous version for instant rollback. This is exactly Vercel’s “immutable deployment plus alias” model, which its own docs describe as separating static assets from compute artifacts at build time.| Option | Type | Why | Watch |
|---|---|---|---|
| Cloudflare R2 | Hosted | Zero egress fees, and a CDN and custom domains built in — the natural home for published sites | Latency to the bucket from a non-Cloudflare node |
| Hetzner Object Storage | Hosted | Same datacentre as Hetzner nodes, cheap, S3-compatible | Egress to the public internet is metered |
| Backblaze B2 | Hosted | Cheapest per GB stored; free egress to Cloudflare | Fine for backups, less so as a hot origin |
| Garage | Self-hosted | Tiny footprint, geo-distributed by design, AGPL | No versioning or object lock yet |
| SeaweedFS | Self-hosted | Mature since 2012, scales large, Apache 2.0 | Master/volume split is real ops work |
| RustFS | Self-hosted | Drop-in MinIO replacement, Apache 2.0 | Young; gaps in object lock and encryption modes |
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.
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.
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.
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.
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.
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.
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.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 example | Assumption | Result |
|---|---|---|
| Tenants | 500 sites, 5% active at peak, hibernation on | ~25 running containers, ~475 stopped |
| Memory | 25 × ~400 MB + proxy + OS | ~12 GB in use of 64 GB |
| Disk | 500 × (50 MB db + 2 GB uploads cap) | ~1 TB if everyone fills their quota; NVMe plus bucket offload keeps local far lower |
| Nodes | one dedicated box for tenants, one small VPS for control, one spare | fits comfortably with headroom for a 2× spike |
| Bill | metal + two small VPS + bucket + CDN | low hundreds of euros a month — check current prices, they moved this year |
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.
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.
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.
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: 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.
| Vercel | Laravel Cloud | Fly.io | Coolify | Kamal | You, per this course | |
|---|---|---|---|---|---|---|
| Isolation unit | Function instance | Pod (container) | Firecracker microVM | Container | Container | Container per tenant |
| Who owns the metal | AWS + own edge | AWS | Fly | You | You | Hetzner/OVH/…, you rent |
| Edge / TLS | Own anycast + TLS terminator | Cloudflare Tunnels | Own anycast + fly-proxy | Traefik or Caddy per server | kamal-proxy per host | Caddy per node, Cloudflare in front later |
| Routing config lives in | Replicated metadata store | K8s objects + Nginx | Corrosion-replicated state | Container labels | kamal-proxy state | Control-plane DB, pushed to Caddy |
| Control → node channel | Internal | K8s API via operator | flyd agent | SSH | SSH | SSH first, agent if NAT forces it |
| Builds | Own build infra | Operator → ECR | Remote builders | On node or build server | Local or remote builder | CI → GHCR, once per release |
| Scale to zero | Native | Hibernation on idle | Autostop / autostart | No | No | Hibernate idle editors; static sites always up |
| State | External | EBS-backed DB pods | Volumes + LiteFS | Volumes + S3 backups | Volumes | Volume + Litestream + bucket |
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.
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.
| Threat | If tenants run only your image | If tenants can run their own code (plugins, custom Ruby, uploaded templates with logic) |
|---|---|---|
| Tenant reads another tenant’s files | Bug 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 process | Per-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 kernel | Requires 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 box | cgroup 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.
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" } }
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.
SECRET_KEY_BASE per tenant, S3 credentials scoped to the tenant prefix with an IAM-style policy, injected as environment at start, stored encrypted in the control plane. Never in the image, never in a compose file on disk in plain text (use an env file with 0600 or Docker secrets).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 happen | How often | What you need to have built already |
|---|---|---|
| Disk fills up — a tenant uploads 30 GB of video, Docker images pile up, logs grow | Constantly, until you fix it | Per-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, legal | Weekly | A 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 reboot | Monthly | Drain: 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 dies | Yearly, on a bad year | Scripted 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 limit | Weekly across a fleet | Expiry 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.app | Weekly once you are visible | Takedown 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” | Weekly | Per-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 dispute | Monthly | Meters: storage GB-days, bandwidth GB, instance-hours, publish count — written by the reconciler and the CDN logs, not estimated |
| Restore drill | Monthly, by policy | A script, a scratch node, and a calendar entry. The only proof a backup exists |
#!/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)"
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.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.
| Table | Key columns | Written by |
|---|---|---|
nodes | hostname, ip, ssh/agent credential ref, capacity, status (active / draining / dead), region | bootstrap script, ops |
releases | version, image digest, has_migration, rolling_safe, created_at | CI |
tenants | slug, plan, status (provisioning / running / hibernated / suspended / deleted), node_id, release_id, secret refs, last_editor_activity_at, quota_gb | signup, reconcilers, billing |
domains | tenant_id, hostname, kind (platform / custom), verification_token, status, verified_at, last_checked_at | dashboard, DNS checker |
publishes | tenant_id, version, bucket_prefix, size_bytes, is_live | the app, on publish |
meters | tenant_id, day, storage_gb, egress_gb, editor_hours, publishes | reconcilers, CDN logs |
audit_events | actor, tenant_id, node_id, command, result, at | everything that touches a node |
| Transition | What the reconciler does | Visible to the customer |
|---|---|---|
signup → provisioning | Pick 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 |
provisioning → running | Health check passed; DNS wildcard already covers the subdomain; certificate is the wildcard | Editor opens |
running → hibernated | No editor request for N hours: stop container, keep volume, keep sidecar snapshot | Nothing — the site is static |
hibernated → running | Caddy’s wake gate sees a request for a stopped tenant: docker start, wait for health, proxy | A 3–8 s first load with a “waking up” page |
→ suspended | Stop container, replace routes with a holding page, freeze publishing, keep everything | Editor locked, site still up (or a notice, your policy) |
→ deleted | Stop, final snapshot, delete volume and CDN mapping, retain bucket prefix 30 days, then purge | Gone, with a grace window |
| migrate A → B | The seven steps from §08, as one job with a resumable step counter | Editor offline for the copy; site untouched |
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
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.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.
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
/data/public, sync to sites/<slug>/v<n>/, then tell the control plane to mark v<n> live. The CDN mapping changes; nothing on the node needs to keep serving it.last_editor_activity_at (batched, once a minute) is what the hibernation job reads./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.ProvisionTenantJob, ReconcileTenantJob, SSH channel. Replace the hand-edited compose file with the reconciler. Nothing the customer sees changes.| Pitfall | How it presents | Fix |
|---|---|---|
| SQLite on a network filesystem | Mysterious corruption under load | Local disk only. Replicate with Litestream; never share the file |
| Serving published sites from the container | One viral page takes the editor of 400 other tenants down | Bucket + 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 localhost | Adding a second node is a rewrite | Node column on day one, even with one node |
| Unrestricted on-demand TLS | Let’s Encrypt rate-limits your whole platform | The ask endpoint; verified domains only |
| Docker socket in a tenant container | Root on the host for the price of an upload | Never. Gate and proxy are trusted platform components; tenants are not |
| No log rotation, no prune | Disk full on a Sunday | daemon.json log limits, nightly prune, quota per tenant, alert at 70% |
| Backups nobody restored | A backup that is a hypothesis | Monthly drill on a scratch node, timed |
| Control plane on the tenant node | The thing that fixes the node is on the broken node | A separate small VPS from the start |
| Reaching for Kubernetes | Months on the platform, no customers | A placement table and a reconciler are the scheduler for one-image-N-tenants |