Module 4 · ~15 min · the crown jewel
🚀 Deploy to your own box behind one reverse proxy
This is the part nobody hands you, and it's why the apps are live and cheap. You'll leave with a repeatable pattern that puts your whole fleet on one small server.
The whole pattern in one picture
One cheap VPS runs Docker. On it: one shared Caddy reverse proxy owns ports 80/443, terminates TLS, and auto-provisions HTTPS certificates. Each app is a tiny static file-server container on a shared Docker network — with no public ports of its own. Caddy routes each domain to the right container. DNS is one A record per app. Updates are a file copy.
Because every app is just "a container + one Caddy block + one DNS record," your tenth app deploys exactly like your first. That sameness is the whole trick — the fleet compounds instead of each app being a new adventure.
Cost reality. One VPS in the $5–7/month range hosts the entire fleet — every app on this course's proof page runs on a single box like that. Not one bill per app. No per-request serverless surprises.
SSH into your box and install Docker
Use the Hostinger VPS you set up in Module 0 (any provider works, but Hostinger + the MCP is what the rest of this lesson assumes). SSH in as root — ssh root@YOUR_VPS_IP — then install Docker and create one shared network every app will join:
# on your Ubuntu VPS (skip the install line if you picked the Docker template)
curl -fsSL https://get.docker.com | sh
docker network create web
mkdir -p /srv/caddy /srv/apps
💡 Don't want to leave your editor? Ask Claude to do it: "SSH into my Hostinger VPS at <IP> and install Docker." With the Hostinger MCP connected (Module 0), Claude can also check the box's status, restart it, or manage its firewall for you.
Run the shared Caddy (your one front door)
Create /srv/caddy/Caddyfile with just a global block to start (an email for Let's Encrypt):
# /srv/caddy/Caddyfile
{
email you@yourdomain.com
}
Then run Caddy — the only container that binds public ports, mounting the Caddyfile and a persistent volume for certificates:
docker run -d --name caddy --restart unless-stopped \
--network web \
-p 80:80 -p 443:443 \
-v /srv/caddy/Caddyfile:/etc/caddy/Caddyfile \
-v caddy_data:/data -v caddy_config:/config \
caddy:2
💡 The caddy_data volume is important — it's where issued certificates live. Keep it and your certs survive restarts and never re-issue needlessly.
Your app = a static file-server container
Drop your index.html and assets into a folder, then run a caddy:2 file-server for them. Note the read-only mount and, crucially, no -p published port — only the shared Caddy is public; your app is reachable only inside the web network.
mkdir -p /srv/apps/yourapp
# copy your static site into /srv/apps/yourapp (index.html, assets…)
docker run -d --name yourapp --restart unless-stopped \
--network web \
-v /srv/apps/yourapp:/usr/share/caddy:ro \
caddy:2
Wire the domain — one Caddy block
Append a block for your app to /srv/caddy/Caddyfile. This is the entire routing + HTTPS config for the app:
yourapp.yourdomain.com {
encode gzip
reverse_proxy yourapp:80
}
Now reload Caddy without restarting it (graceful, zero downtime for your other apps). Use the stdin form — it feeds Caddy the fresh file contents directly:
docker exec -i caddy caddy reload --config - --adapter caddyfile < /srv/caddy/Caddyfile
Caddy sees the new domain and automatically requests a Let's Encrypt certificate for it. HTTPS just works.
Point DNS — just ask Claude
Your app needs one DNS record: an A record for yourapp → your VPS's IP. This is where the Hostinger MCP (Module 0) earns its keep. You don't open a control panel — you ask Claude, in plain English:
EXAMPLE — WHAT YOU TYPE, AND WHAT CLAUDE DOES:
That's the whole DNS step — no panel, no copy-pasting IPs into forms. It's exactly how every app in the fleet gets its domain. (Prefer clicking? You can still add the A record in Hostinger's DNS panel by hand.) Then verify:
curl -sI https://yourapp.yourdomain.com | head -1
# HTTP/2 200 ← live, with a valid cert
⚠️ The cert needs DNS to resolve first. If you just added the record, Let's Encrypt may briefly see NXDOMAIN — harmless, Caddy retries automatically once DNS propagates (usually a minute or two).
Updates are a copy
No rebuild, no redeploy dance. The file-server serves whatever's in the folder, live:
scp index.html root@YOUR_VPS_IP:/srv/apps/yourapp/
# refresh the page — it's already updated
Adding app #2, #3, #10
Every new app is the same three moves you just did:
- Run its static container on the
webnetwork (no published port). - Append its Caddy block, reload.
- Add its DNS record.
That's it. No new infrastructure, no migration, no per-app hosting bill. This is how a fleet gets built on evenings — the deploy stopped being a decision.
Gotchas worth knowing (they'll save you an hour)
- Single-file Caddyfile mount. Because the Caddyfile is bind-mounted as a file, replacing it (some editors,
scp,sed -i) creates a new inode and the container keeps reading the old one. Edit it in place (append withcat >>) and reload via the stdin form above — that feeds fresh contents regardless of inode. - Never
restartCaddy for a config change. Alwaysreload. A restart drops every app's connection for a moment; reload is graceful and instant. - Something already owns port 80? (An existing web server on the box.) Then Caddy can't use the HTTP-01 challenge — but it still gets certs fine over the TLS-ALPN challenge on 443. Just be aware if you see HTTP-01 errors in the logs.
- Keep your Caddyfile in version control locally. The server copy is the source of truth for routing; a local mirror means a rebuild can never silently drop a route.
🦥 Why this beats a platform. No cold starts, no per-seat/per-request pricing, no vendor lock-in, no build minutes. A flat monthly cost you understand, and scp to ship. Boring on purpose — boring is what's still running in a year.
Your turn ✅
- Spin up a VPS, install Docker, create the
webnetwork. - Run the shared Caddy with a persistent
caddy_datavolume. - Deploy your Module 3 static app as a file-server container (no published port).
- Add its Caddy block + reload, add its DNS record.
- Load it over HTTPS from your phone. That's a real, live app. 🎉