🚀 Deploy behind one front door
The pattern every fleet app runs on: a tiny container per app, one shared Caddy, automatic HTTPS, and reload habits that keep the other sites up.
You'll leave with
- Your app live at its own subdomain with HTTPS
- A Caddyfile you edit locally first and reload without restarting anything
- A repeatable deploy plus a curl checklist that proves it worked
Your app runs on your laptop, and a server has been waiting for it since the last lesson. This lesson joins them, with the routine behind every app in the fleet, plus the two habits that stopped my sites from vanishing.
⚡ The 60-second version
- Once per server: a Docker network called
weband one Caddy container, the front door, owning ports 80 and 443. - Per app: a DNS record, a folder of files, a tiny
caddy:2container onwebwith no ports, and a short block in the master Caddyfile. - Validate, reload (never restart), prove it with curl. After that, an update is a file copy.
The pattern every fleet app runs on
Every static app I run, from Acento to The List, went live this way (apps with a backend, like my price tool, add a build step, covered near the end):
visitor ── https ──▶ caddy the front door: owns ports 80 and 443, gets certificates
├──▶ myapp caddy:2 file server for /root/myapp (read-only)
├──▶ otherapp caddy:2 file server for /root/otherapp
└──▶ api a backend, if you ever need one
all on one private Docker network called "web"; only caddy has public ports
What's a container (and an image)?
An image is a recipe for a sealed box that runs one program; a container is the box, running. caddy:2 is Caddy's official image.
What's a reverse proxy?
The program at your server's front door. Every request arrives there first, and it hands each one to the right app by the name asked for. Ours is Caddy, which also handles HTTPS.
Both the front door and each app's file server are Caddy: one program in two jobs. The front door routes visitors and gets certificates. Each app's tiny caddy:2 copy only hands over the files in one folder.
Per app you need five things:
- a folder on the server (
/root/myapp;/rootis the admin user's home) - a tiny
caddy:2container that serves that folder read-only - a spot on the shared network, with no published ports
- a short block of config in the master Caddyfile
- a DNS record
What are a Docker network and a published port?
A Docker network is a private network only containers can join, where they reach each other by name (http://myapp). A port is a numbered door; web traffic uses 80 and 443. Publishing one (-p 443:443) opens it to the internet, and only the front door does.
What's a mount?
Plugging a server folder into a container, which sees it at a path of its own. :ro makes it read-only.
With no published ports, a database or admin panel you add later can't end up open to the internet by accident. Updating is copying files: the folder is mounted live, so a copied file is served on the next request, with no restart. Traqqit Max runs in exactly this kind of container. Adding Acento touched nothing else. And the twentieth app takes the same steps as the first.
🦥 What we skip: Kubernetes (software for running containers across many servers), build pipelines (machinery that builds and releases your code automatically) and a separate hosting platform for every app. You'd add them when one server is full or downtime costs real money; mine hasn't needed it yet. Boring is what's still running in a year.
Set up the front door once
Once per server. Blocks labeled Your computer run on your laptop; On the server means a terminal logged in with ssh root@YOUR_VPS_IP. Keep two terminal tabs open in VS Code (the + in the terminal panel opens a new one): one stays on your laptop, in the other you run ssh root@YOUR_VPS_IP and leave it logged in. Type exit to leave the server. A No such file error for a file you know you have usually means you are in the wrong tab.
On your laptop: the master copy
Every site depends on the front door's config, so its master copy lives on your laptop in git, and the server only gets copies. Make a private repo called infra next to your app's folder (the way you made your app's repo in Set up your workshop), holding one file, Caddyfile, with no extension. In VS Code's terminal, make the folder, then tell Claude Code it may work there (this lasts for the current session):
mkdir ../infra
Type /add-dir ../infra in the Claude Code box, then ask:
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: the same git-and-GitHub routine as your app's repo, including the .gitattributes that keeps line endings Linux-friendly; the only new thing is that this folder holds the front door's one config file. The file should contain exactly this:
# The front door. MASTER COPY: edit here first, then copy to the server.
{
email you@yourdomain.com
}
What's a Caddyfile?
Caddy's settings file. Each site block starts with an address and lists what to do with its requests, one instruction per line. The block with no address at the top holds settings for Caddy itself.
Replace you@yourdomain.com with your real address. The email line is the contact Caddy registers with the certificate authority, the free service (Let's Encrypt) that issues your padlock certificate. It also keeps the file from being empty: Caddy refuses a zero-byte Caddyfile.
On the server: the network and the front door
EXAMPLE — YOU ASK CLAUDE CODE
serving initial configuration.↳ Bash · ssh root@YOUR_VPS_IP · docker network create web · scp · docker runWhat's happening underneath:
docker network create web
mkdir -p /root/caddy
scp Caddyfile root@YOUR_VPS_IP:/root/caddy/Caddyfile
docker run -d --name caddy --restart unless-stopped --network web \
-p 80:80 -p 443:443 -p 443:443/udp \
-v /root/caddy:/etc/caddy \
-v caddy_data:/data -v caddy_config:/config \
caddy:2
In order: run in the background, named caddy, back after crashes and reboots, on web, the public ports (UDP serves HTTP/3, a faster HTTPS), the config folder, and two volumes.
What's a volume?
Storage that survives when a container is deleted and recreated. caddy_data holds your certificates.
Mount the folder, not the file. Many guides mount the single Caddyfile. With the folder mounted, Caddy sees every edit you copy over, and a trap that once took several of my sites down can't happen.
Check it yourself: docker ps lists caddy as Up, with ports like 0.0.0.0:443->443/tcp, and docker logs caddy ends with:
Nothing answers a browser yet: Caddy has no site to serve until step 3, and that is expected. If docker ps shows Restarting instead of Up, run docker logs caddy and read the first error (usually a typo in the Caddyfile, or no Caddyfile in /root/caddy).
⚠️ Address already in use means another program owns port 80 or 443, often a leftover Apache (see Your own server). Run docker rm caddy, then ask Claude: Something already uses port 80 on my server. Show me what it is and wait for my OK before you stop or disable it. Then run the front door's docker run again. (Docker may say port is already allocated instead when another container owns the port: same cure.)
🛑 Never delete the caddy_data volume. Lose it and Caddy requests every certificate again at once, which can hit Let's Encrypt's rate limits.
Your first deploy, by asking
The deploy is one prompt, but DNS comes first. One reminder for everything below: myapp, yourdomain.com and the file names are examples. Use your app's short name, your real domain and the files your app really has.
Point the name, then wait
Let's Encrypt checks your DNS the moment Caddy asks for a certificate, so the record goes first, and you wait until the world can see it. That's why The List got its certificate on the first try.
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath: an A record ("myapp lives at this IP address"), added through the Hostinger MCP from Your own server in add mode, so your other records survive; the lists before and after are your proof. Then Claude polls public resolvers until they agree.
What's a public resolver?
A big DNS server anyone can ask, like Cloudflare's 1.1.1.1. It shows what the internet sees, not what your laptop remembers.
Check it yourself (this works in PowerShell too):
nslookup myapp.yourdomain.com 1.1.1.1
It must print YOUR_VPS_IP before you add the block. NXDOMAIN or Non-existent domain means the record hasn't spread yet: wait a few minutes and ask again.
What's NXDOMAIN?
DNS's answer for "no such name". Some resolvers give it for a few minutes after you create a record.
Ship it with one prompt
If Claude Code is open in your app's folder, type /add-dir ../infra so it can edit the master Caddyfile too (it only lasts for the current session, so type it again whenever you start a new one).
EXAMPLE — YOU ASK CLAUDE CODE
curl -I answers HTTP/2 200.↳ Bash · scp · ssh root@YOUR_VPS_IP · git diff --no-index · git commit -m "Add myapp"What's happening underneath, so you can follow along the first time:
The files and the app's container
ssh root@YOUR_VPS_IP 'mkdir -p /root/myapp'
scp index.html style.css app.js data.json root@YOUR_VPS_IP:/root/myapp/ # the files your app really has
docker run -d --name myapp --restart unless-stopped --network web \
-v /root/myapp:/usr/share/caddy:ro caddy:2
# can the front door see it? ask from inside caddy itself:
docker exec caddy wget -qO- http://myapp/ | head -3
Only the files you name reach the server. caddy:2 serves /usr/share/caddy on port 80, so your folder mounted there is the website; with no -p, it publishes nothing. The last line fetches your app by name from inside the front door, as every visit will: you should see the top of your index.html. bad address 'myapp' means it isn't on web, or the name is misspelled.
The site block: master copy, then server
myapp.yourdomain.com {
encode zstd gzip
reverse_proxy myapp:80
}
For this name: compress responses (a big data.json travels at a fraction of its size) and hand everything to myapp on port 80. HTTPS needs no line; Caddy handles it.
Keep exactly one block per name. The two blocks later in this lesson (cache headers, private paths) are more lines to add inside this same block, not new blocks: a second block with the same name fails validation with ambiguous site definition.
Next, the server gets a copy under a temporary name, and a throwaway Caddy checks it first:
git add Caddyfile
git commit -m "Add myapp"
scp Caddyfile root@YOUR_VPS_IP:/root/caddy/Caddyfile.new
docker run --rm -v /root/caddy:/etc/caddy:ro caddy:2 \
caddy validate --config /etc/caddy/Caddyfile.new --adapter caddyfile \
&& mkdir -p /root/backups \
&& cp /root/caddy/Caddyfile /root/backups/Caddyfile.$(date +%F-%H%M) \
&& mv /root/caddy/Caddyfile.new /root/caddy/Caddyfile \
&& docker exec -w /etc/caddy caddy caddy reload
Each && means "only if the previous command worked", so a file that fails validation is never swapped in: you see the error instead of Valid configuration, and nothing else runs. The same pattern guards the tarball upload below.
Reload or restart?
A reload hands the running Caddy a new config, which it checks and swaps in without dropping a visitor, or refuses if it's broken. A restart stops Caddy, so every site goes dark, and stays dark if the config has an error.
The --rm container checks the file and vanishes; an "is not formatted" warning is about indentation, not an error. -w /etc/caddy runs the reload in the config folder, where Caddy finds its Caddyfile; it prints a few log lines and no error. And that mv would break a single-file mount; with the folder mounted, it's harmless.
Prove it from outside
"It deployed" means nothing until you've checked from outside, as a stranger would. By hand first:
curl -I https://myapp.yourdomain.com
In Windows PowerShell, type curl.exe -I instead (plain curl there is a different command). The first line is the verdict:
What's curl?
A command that fetches a web address and shows the raw answer. -I asks for the headers only, which start with a status code.
| You see | It means | Do this |
|---|---|---|
HTTP/2 200 | It worked | Open it on your phone and find the padlock |
404 | Caddy reached your app, but that file isn't in /root/myapp | Upload it. Linux cares about capitals: Logo.png and logo.png are different files |
502 | Caddy can't reach the container | A typo in the name or network: check docker ps, then docker network connect web myapp. For your own server code: make it listen on all addresses (0.0.0.0), not just localhost |
| An SSL or TLS error, no status | No certificate for that name yet | Wait a minute, then see the certificates section |
Could not resolve host | Your laptop has not learned the new name yet | Repeat the nslookup check from step 2; if 1.1.1.1 answers, wait a few minutes (a phone on mobile data is a quick second opinion) |
Connection timed out or Connection refused | Nothing is answering on port 443 | Check docker ps shows caddy Up, and that ports 80 and 443 are open in your hosting panel's firewall (Your own server) |
certificate problem in curl, or "Not secure" in a browser | Caddy is holding a practice certificate from Let's Encrypt's staging server | See the certificates section and look for acme-staging in the log |
EXAMPLE — YOU ASK CLAUDE CODE
bash check.sh https://myapp.yourdomain.com.↳ Bash · curl · Write check.shWhat's happening underneath, in a bash terminal (Mac, Linux, or Git Bash on Windows: the ▾ next to the + in VS Code's terminal panel opens one):
U=${1:-https://myapp.yourdomain.com} # the address to test; pass another as the first argument
curl -sI $U | head -1 # HTTP/2 200
curl -s $U | grep -o '<title>.*</title>' # your page's title
for f in / /style.css /app.js /data.json; do # use the files your app really has; a one-file app checks just /
curl -s -o /dev/null -w "%{http_code} %{content_type} $f\n" $U$f
done # every line starts with 200
curl -s -o /dev/null -w '%{http_code}\n' $U/.env # 404
curl -s -o /dev/null -w '%{http_code}\n' $U/.git/config # 404
curl -s -o /dev/null -w '%{http_code}\n' --path-as-is $U/../../etc/passwd # 404
What's a path trick?
A request like /../../etc/passwd, where each .. means "up one folder": an attempt to climb out of your site into the rest of the server. Curl tidies those away unless you add --path-as-is.
The last three lines matter as much as the first: what should be blocked must be blocked. The fuller version of this list (it also checks login-only routes and leftover template placeholders) has caught missing files, unwired routes and leftover placeholders on Grease, Handstand and this course's own site, and proved the course pages can't be reached with path tricks.
Bash scripts need Unix line endings (LF). A file saved with Windows line endings makes bash print $'\r': command not found: ask Claude to convert the file to LF (the .gitattributes from Set up your workshop prevents it in git).
Why we mount the folder
Here's the trap the folder mount spares you, which you'll meet in other people's setups. A container that mounts a single file is tied to that file's inode, not its name.
What's an inode?
A file's identity on disk, separate from its name. Edit a file in place and it keeps its inode; write a new file and rename it over the old one, and the name points at a new inode.
Many tools save by writing a new file and renaming it over the old one: sed -i, mv, rsync, tar, editors with a "safe save". The container keeps reading the old file, so a reload changes nothing and the log says config is unchanged. (My notes blamed scp, but plain scp overwrites the existing file in place, so another step in that deploy most likely swapped it. The cure is the same either way.)
📍 While adding the price tool, this cost me an hour, and the tempting fix, a restart, would have knocked every other site offline. Later, a stale reload dropped several live sites for about ten minutes. The clues: Caddy's certificate log listed too few domains, and curl to the missing sites printed 000 (no answer at all).
Two rules. Never restart the shared front door: a restart drops every site at once, and if the config is broken, none come back. And if you're stuck with a single-file mount, pipe the file in, so Caddy reads exactly what you send:
docker exec -i caddy caddy reload --config - --adapter caddyfile < /root/caddy/Caddyfile
What's stdin?
Standard input, the stream a command reads from. < feeds a file into it, and --config - tells Caddy to read its config from there.
Once the file is orphaned (check with docker exec caddy cat /etc/caddy/Caddyfile | diff - /root/caddy/Caddyfile; no output means the container sees the same file as the disk), copying it again won't help; only recreating the container re-attaches it: docker rm -f caddy, then the same docker run. Certificates survive in the volume, but every site blips, so do it once and switch to the folder mount.
Master copy first
The second habit, and the day it was born:
📍 Handstand's site block existed only on the server. A parallel redeploy of another app rebuilt the server's Caddyfile from a local copy that had never seen it, and dropped it without an error. Every reload after that pushed the block-less config into the live front door, so the new site's certificate never issued.
Anything that exists only on the server will eventually be overwritten by whatever regenerates it, more so when several AI sessions deploy at once. So the order never changes: edit infra/Caddyfile and commit, copy it over, validate, reload. Before any deploy that touches the Caddyfile, diff the two copies; anything only on the server goes into the master first.
EXAMPLE — YOU ASK CLAUDE CODE
What's happening underneath (PowerShell too):
scp root@YOUR_VPS_IP:/root/caddy/Caddyfile ../server.Caddyfile
git diff --no-index ../server.Caddyfile Caddyfile
What's a diff?
A line-by-line comparison of two files. Lines only in the first start with -; lines only in the second start with +.
No output means they match. A - line exists only on the server: stop and copy it into the master. A + line is your change, waiting to go live. First Bites' block sits in the master with a comment telling future me to keep it.
Certificates for a brand-new subdomain
Caddy fetches the padlock's certificate by itself when a block with a real name loads, and it succeeds only once the world's DNS can find that name.
What's Let's Encrypt (and ACME)?
A free certificate authority that browsers trust to vouch a site really is yourdomain.com. ACME is how Caddy proves to it that you control the name, then collects and renews the certificate.
Ask too early and Let's Encrypt gets NXDOMAIN and refuses. Caddy retries by itself, but the gaps grow (a minute, two, five, later hours), and the retries quietly go to Let's Encrypt's staging server. The log shows it (look for acme-staging in the lines you will grep below), and so does a browser warning that the certificate is not trusted.
What's the staging server?
Let's Encrypt's practice server, whose certificates no browser trusts. Caddy retries there, then goes back to the real one once a practice run passes.
That's what happened to Handstand: reloading did nothing (config is unchanged) until the block was removed and added back. A comment won't nudge it either, because Caddy compares its parsed config and comments vanish in parsing; one of my sites sat without a certificate while I tried exactly that. A real change starts a fresh attempt against the real server, right away:
EXAMPLE — YOU ASK CLAUDE CODE
certificate obtained successfully, and the server's Caddyfile matches infra/Caddyfile.↳ Bash · nslookup · docker logs caddy · caddy reload ×2 · diffThe master copy never changes in this dance. It is your proof of what the server must look like at the end, and the diff is how you know it does.
What's happening underneath: resolvers, the log, two real config changes, and a diff to prove the block came back. Read the log yourself:
docker logs --tail 50 caddy 2>&1 | grep -i myapp
Look for obtaining certificate, then certificate obtained successfully or could not get certificate from issuer with the reason. (Caddy has a second free authority, ZeroSSL, built in as a backup, so the issuer named in those lines may be either; that is expected.)
What are http-01 and tls-alpn-01?
Two ways to prove you control a name: Let's Encrypt fetches a one-time file over port 80 (http-01), or checks a special handshake over port 443 (tls-alpn-01). Caddy can use either.
Two more things you may meet, neither of them your config:
- Port 80 is blocked (say, by a firewall): http-01 fails, but tls-alpn-01 over port 443 can still succeed, so the log may show a failed http-01 line followed by
certificate obtained successfully. Keep 443 reaching Caddy. (If another program owns port 80, Docker will not start the front door at all: see the warning in step 1.) - Let's Encrypt is down: 500 or 503 errors from them in the log. Leave your config alone and check their status page. One outage cleared by itself after about 25 minutes, and when Ship hit a temporary error of theirs, Caddy's next retry fixed it.
Uploads that can't half-break the site
Copying files is the deploy, so copy them in ways that can't leave the site half old and half new.
Steps 1 to 4, plus the folder-mount and master-copy habits after them, are the core. This section and the next are what you will want within your first few deploys: do the tarball, backup and secrets parts before your second deploy, and skim the hotfix and push-to-deploy parts until you need them.
One tarball, not three hundred files
Copying Acento's 308 audio clips as separate files died at about 70 with Connection reset by peer. One tarball of about 8 MB went up cleanly, and so did every later batch (268 clips, then 516), holding only the new files.
What's a tarball?
Many files packed into one compressed archive by the tar command (Windows 10 and later include it). It travels as a single file, so a failed copy is one clearly broken file instead of a half-updated site, and gzip -t checks it before you unpack.
tar -czf audio.tgz audio
scp audio.tgz root@YOUR_VPS_IP:/tmp/
ssh root@YOUR_VPS_IP 'gzip -t /tmp/audio.tgz && tar -xzf /tmp/audio.tgz -C /root/myapp && rm /tmp/audio.tgz'
Then curl -sI https://myapp.yourdomain.com/audio/NEW_FILE.mp3 should show 200 and audio/mpeg. If gzip -t says unexpected end of file instead, the upload was cut short and nothing was unpacked: run the scp line again.
Unpacking never deletes, so renamed files linger until you remove them. Delete the files inside /root/myapp, never the folder itself: the container is attached to that exact folder, and if you recreate it, the site looks empty until you run docker restart myapp.
Back up, then ship an allowlist
Before a risky change, copy the live folder to a backup named for the change, as Acento did before risky deploys (pre-leaderboard) and First Bites before its big Google Play preparation release. Keep backups in /root/backups: beside index.html, anyone who guesses the name could download them.
ssh root@YOUR_VPS_IP 'mkdir -p /root/backups && cp -a /root/myapp /root/backups/myapp.$(date +%F).pre-redesign'
To roll back, copy the backup over the live folder (ls /root/backups on the server shows its exact name). Files you added since stay, so delete those by hand.
ssh root@YOUR_VPS_IP 'cp -a /root/backups/myapp.2026-10-02.pre-redesign/. /root/myapp/'
Try it once on a harmless change, so the first time you need it is not the first time you have run it.
Then pack what ships:
tar -czf release.tgz index.html style.css app.js data.json # the files your app really has
That line is an allowlist: only the files you name ship. Never send the whole project folder; it holds .git (your whole history, including any secret you ever committed), notes and scripts. One of my side projects ships exactly six named files so its private runbook can't land on the web, and when its server grew a new module, the list grew too.
EXAMPLE — YOU ASK CLAUDE CODE
A hotfix ships from what production runs
The newest code on your laptop often isn't what's live. A title fix for the price tool was ready, but the repo also held three newer commits meant for something else, and shipping the working folder would have released them all. So the fix went on top of the exact commit production runs, and only the two changed files shipped.
What are a tag and a worktree?
A tag is a permanent name for one commit. A worktree is a second folder with your repo checked out at another commit, so you can fix what's live without touching your current work.
git tag prod-2026-10-02 # after you commit and deploy: mark what went out (deployed twice today? use prod-2026-10-02-b: a tag name can only be used once)
git worktree add ../myapp-prod prod-2026-10-02 # later: a clean copy of exactly that
EXAMPLE — YOU ASK CLAUDE CODE
The self-check is the one from Check the AI's work.
Make browsers drop old files
Browsers keep copies of files, so a returning visitor can get your new HTML with old CSS or JavaScript, a broken page you can't reproduce. Version the stylesheet link and bump it with every CSS change, as the traqqit.com home page does:
<link rel="stylesheet" href="style.css?v=7">
That covers the stylesheet. For everything else, a file served with no caching instructions can be reused for hours without asking the server. So tell browsers to check every file every time; unchanged files come back as a tiny "not modified", so it costs almost nothing. Add one line inside your myapp block (in infra/Caddyfile first, then shipped the way you did in step 3):
header Cache-Control "no-cache"
Check: curl -sI https://myapp.yourdomain.com/ | grep -i cache-control prints cache-control: no-cache, and so does the same command for /app.js (in plain PowerShell: curl.exe -sI https://myapp.yourdomain.com/app.js | Select-String cache-control). Keep the ?v= advice above as a second layer. Got a service worker (the offline helper in Make it an app on their phone)? Bump its cache version too.
🦥 Skipped until copying by hand gets old: push-to-deploy, where a GitHub Actions workflow runs rsync on every push. Its key is locked on the server to writing one folder (the line below), so a leaked secret can't open a shell. Ask Claude to set it up; if GitHub refuses the workflow file, run gh auth refresh -s workflow.
command="rrsync -wo /root/myapp",restrict ssh-ed25519 AAAA... deploy-myapp
Secrets never in the web root
Everything in the served folder is downloadable by anyone who guesses the name, and bots try names like /.env all day.
📍 While adding push notifications to one fleet app, I let its keys file land in the app's folder, which is the website: downloadable by anyone, for a few minutes. I moved it to a locked-down file outside the folder and rotated the keys anyway. Bots scan for files like that constantly, and "probably nobody saw it" is a guess.
What does rotating a key mean?
Making a new key, switching your app to it, and retiring the old one, so a leaked copy stops working.
The same goes for data your own server code saves (an accounts.json, uploads, or a SQLite file, a small database kept in one file): give it its own folder outside the one the file server mounts, and curl its path to prove it returns 404. Add a backend only when forced sets this up.
Secrets get their own folder that no web server mounts:
mkdir -p /root/secrets && chmod 700 /root/secrets
nano /root/secrets/myapp.env # paste the keys here, never into a chat
chmod 600 /root/secrets/myapp.env
What do chmod 700 and 600 do?
They set who may open a folder or file. With 700 on the folder and 600 on the file, only the owner (root) can get in, read or write.
In nano (a simple text editor that runs in the terminal), paste, press Ctrl+O and Enter to save, and Ctrl+X to leave.
An .env file is plain text, one NAME=value per line. Give it only to the container that needs it, never to the file server: add --env-file /root/secrets/myapp.env to that container's docker run, and the values appear inside it as environment variables (settings a program reads when it starts). Add a backend only when forced shows the full command.
As a second lock, the front door can refuse the usual suspects even if a file slips in. It is a net, not a promise: a file called accounts.json would still be served, so the real rule stays the same: keep private files out of the folder. Add these two lines inside the myapp block:
@private path /.git/* /.env* *.env *.bak *.old *.zip *.tgz *.sql
respond @private 404
Put together, your block now reads (ship it the way you did in step 3: master copy, copy up, validate, reload):
myapp.yourdomain.com {
encode zstd gzip
header Cache-Control "no-cache"
@private path /.git/* /.env* *.env *.bak *.old *.zip *.tgz *.sql
respond @private 404
reverse_proxy myapp:80
}
EXAMPLE — YOU ASK CLAUDE CODE
🛑 If a secret was ever served, even for a minute, rotate it. Don't try to work out whether anyone grabbed it.
Apps that build, and the launch checklist
No backend yet? Skip to the launch checklist below. A static app is already fully deployed (Add a backend only when forced covers the day you need one). An app that has to be built into its own image deploys like this: pack, upload, rebuild, wait until the app answers, then reload Caddy. The tiny Node server in that lesson is not built: its code is mounted, so after an edit you only restart that one container. The price tool taught me the order: a rebuilt container gets a new internal address, and the site answered 502 until Caddy was reloaded, or stayed on 502 when I reloaded before the app had started. The server half, for a backend myapi on port 8000:
#!/usr/bin/env bash
set -euo pipefail # stop at the first error
mkdir -p /root/myapi && cd /root/myapi && tar -xzf /root/myapi.tgz
docker compose up -d --build --no-deps myapi # name the service; --no-deps leaves the rest alone
for i in $(seq 1 90); do # wait up to 3 minutes for a real answer
docker exec caddy wget -qO /dev/null --timeout=3 http://myapi:8000/ && break
[ "$i" -eq 90 ] && { echo "TIMEOUT: myapi never answered"; exit 1; }
sleep 2
done
docker exec -w /etc/caddy caddy caddy reload
curl -sSI https://myapi.yourdomain.com/ | head -1
The laptop half, which also works in PowerShell:
tar -czf ../myapi.tgz --exclude=node_modules --exclude=data --exclude=.git --exclude=.env .
scp ../myapi.tgz root@YOUR_VPS_IP:/root/myapi.tgz
scp redeploy.sh root@YOUR_VPS_IP:/root/redeploy-myapi.sh
ssh root@YOUR_VPS_IP 'bash /root/redeploy-myapi.sh'
You should see Docker's build output, then HTTP/2 200 as the last line. TIMEOUT: myapi never answered means the app never started: on the server, run cd /root/myapi && docker compose logs --tail 50 myapi.
Point that wait at an address your app answers with a normal 200 (a /health route is ideal): if / returns 404 the loop waits three minutes and reports a timeout for a healthy app. The last line also needs what any app needs: a DNS record and a front-door block for myapi.yourdomain.com whose one line is reverse_proxy myapi:8000, added the way you added myapp.
What's docker compose?
A file, compose.yaml, describing one or more containers, plus a command that builds and starts them. Each container in it is a named service.
Always name the service: a bare docker compose up -d touches every service in the file, and if the front door ever lives in that file it can be recreated and take every site down. The service must join web, too:
services:
myapi:
build: .
restart: unless-stopped
networks: [web]
networks:
web:
external: true
Pin your dependencies: an image that installs "whatever is newest" ships any new release, broken or compromised, on your next deploy. The price tool installs an exact list frozen from its running containers. Install before copying code, and Docker reuses that slow step:
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev # exactly the versions in the lock file
COPY . .
CMD ["node", "server.mjs"]
What's a Dockerfile?
The recipe Docker builds an image from: a base image, the files to copy in, what to install, and what to run.
What's npm and a lock file?
npm is Node's package installer. package.json lists the packages you use; package-lock.json, written by npm install, records the exact version of every one, and npm ci installs exactly that list. No packages at all? Then you have neither file and need no Dockerfile: the tiny server in Add a backend only when forced runs straight from the stock image.
Add a .dockerignore file next to the Dockerfile so COPY . . cannot sweep in what doesn't belong:
.env
.git
node_modules
EXAMPLE — YOU ASK CLAUDE CODE
The launch checklist
Baby Signs went live with exactly this list, and so does every new app:
- DNS: the A record, and
nslookup myapp.yourdomain.com 1.1.1.1answers YOUR_VPS_IP. - Container: files from the allowlist, the app on
web, no ports. - Block: in the master copy and committed first, after a diff against the server's copy shows nothing only the server has.
- Validate in a throwaway container.
- Reload, never restart.
- HTTPS:
curl -Ishows 200, and your phone shows the padlock. - Privacy:
/.env,/.git/configand any backup names return 404. - Analytics at launch, not weeks later, or you'll have nothing to compare against (next lesson).
- Live smoke test: your production test from Check the AI's work, pointed at the real address.
- Hub card: a card for the app on your own home page, if you have one (Launch, honestly covers building it once you have two apps). Skip it if the app is launching unlisted (live, but linked from nowhere).
Underneath it all, Caddy reaches each app by container name over the private network, so nothing but the front door faces the internet.
Your turn
- Create the
infrarepo with its Caddyfile committed, and start the front door:docker psshowscaddyUp with ports 80 and 443. - Add the A record for
myappand wait untilnslookup myapp.yourdomain.com 1.1.1.1prints YOUR_VPS_IP. - Deploy your app, get
HTTP/2 200fromcurl -I(orcurl.exe -I), and find the padlock on your phone. - Run
check.sh: the right title, every file 200, and 404 for/.env,/.git/configand the--path-as-istrick. - Diff the server's Caddyfile against
infra/Caddyfile: no line starts with-. - Write the exact deploy and rollback commands into your app's
NOTES.md(from Work with Claude Code), then try the rollback once on a harmless change.
Your app is live, but you can't tell whether anyone's using it. Next, Know if it's working puts cookieless analytics behind this same front door and proves a real visit lands.