Caddy and Astro on one small VPS
Serving a static Astro site with Caddy next to other apps, with a deploy that only swaps in a build that passed.
This site is a folder of static files on a small ARM server. The same server runs the coding agents that help build it, a PostgreSQL that every app shares, and a Node app. Caddy sits in front of all of it. This post covers how that fits together: the Caddy block, a deploy that can’t take the live site down with a bad build, and a build that refuses to pass with a warning in its output.
One server, several apps
The rule for the box is that each app gets three things: a domain, a live copy in /srv/apps/<app>, and a line in a server map that says what runs where. A static site like this one is served straight from files. A Node app runs as a systemd user unit and gets its own port, and Caddy proxies its domain to it.
There’s no Docker and no connection pooler. PostgreSQL already runs natively on the server, one instance with a database per app, and plain systemd services are fewer moving parts on a box with one owner. When a new app needs a database, it gets createdb, not a container.
The live copy is a separate checkout from the ones where work happens. Agents and I edit in our own checkouts and worktrees, push a branch, and merge to main. Only the deploy script touches /srv/apps/. That separation is what lets several pieces of work go on at once without anyone breaking the live site halfway through an edit.
The Caddy block
At launch, all of hauntedpavement.com was this:
hauntedpavement.com {
root * /srv/apps/hauntedpavement/.live
file_server
}
www.hauntedpavement.com {
redir https://hauntedpavement.com{uri} permanent
}
Caddy gets and renews the HTTPS certificates by itself, so there’s nothing else to configure for TLS. The www block sends everything to the bare domain, keeping the path.
One detail of file_server shaped the Astro config. A request for /posts finds a directory, and Caddy redirects it to /posts/. Astro builds every page as <path>/index.html, so the site uses the trailing-slash form everywhere, and every link it prints is already the URL Caddy serves without a redirect:
export default defineConfig({
site: 'https://hauntedpavement.com',
trailingSlash: 'always',
build: { format: 'directory', inlineStylesheets: 'always' },
// …
});
trailingSlash: 'always' also makes the dev server reject the other form, so a link without the slash shows up while writing, not in production.
A deploy that can’t break the live site
Publishing is one command, deploy hauntedpavement. Each app has a small settings file that names its kind (static or node), its domain, and its build output folder. For a static app, the script does this:
git fetch -q origin "$BRANCH"
git reset -q --hard "origin/$BRANCH"
build || die "build failed; the live site is unchanged"
mkdir -p .live
rsync -a --delete "$OUT/" .live/
Caddy never serves dist/. It serves .live/, and .live/ only changes after pnpm install --frozen-lockfile and pnpm build have both passed. The script also checks that the output has a home page, and stops if it doesn’t. Astro empties dist/ at the start of every build, so serving it directly would mean a failed build leaves an empty site behind. With the copy step in between, a failed build leaves the live site exactly as it was, and the script says so.
The copy itself isn’t atomic: for the moment rsync runs, the folder holds a mix of the old and new files. For a personal site that’s a fair trade. If it ever matters, the next step is to build into a fresh folder per release and swap a symlink to it.
A Node app gets the same idea in a different shape. A failed build resets the checkout to the commit that was live, rebuilds it and restarts the service, so the app keeps running the last version that worked.
A build that fails on any warning
deploy runs pnpm build, which means whatever pnpm build checks guards every deploy too. On this site it checks more than Astro does.
First, a short script reads every code file under src/ and fails, naming the file and line, if anything outside one module reads the content collections. That module, src/lib/content.ts, is the only place that applies the rule “drafts aren’t published”. A page that called getCollection() itself would skip that rule, and a draft could leak through one route, such as a tag page.
Then the build runs through a wrapper that watches its output line by line and fails if any line looks like a warning:
const WARNING_PATTERNS = [
/\[WARN\]/, /\[Shiki\]/, /^\(!\)/, /Warning:/,
];
The patterns cover Astro’s own warnings, Shiki’s (an unknown language in a code fence), and Node’s (DeprecationWarning:, ExperimentalWarning: and the rest). Astro is happy to build a site with warnings, and that’s how a missing code language or a deprecated option can go unnoticed for months. Here the first such line fails the build, and so blocks the deploy, until its cause is fixed.
The wrapper reads the output with readline, so a pattern split across two chunks of output still matches. It sets NO_COLOR so colour codes can’t split a pattern either. It also drops FORCE_COLOR, because the test runner sets it, and with both set Node prints a warning of its own, which the gate would then catch.
The end-to-end tests build a second copy of the site from fixture content, into a separate folder, and the production build refuses to start if the variable that points it at fixtures is set. Test content can’t reach a deploy by accident.
Added after launch
The first block was the minimum. It didn’t compress responses, and a missing page got Caddy’s empty 404 instead of the site’s own. A few lines fixed both, and this is the block that runs now:
hauntedpavement.com {
root * /srv/apps/hauntedpavement/.live
encode zstd gzip
header /_astro/* Cache-Control "max-age=31536000, immutable"
file_server
handle_errors 404 {
rewrite * /404.html
file_server
}
}
Files under /_astro/ have a content hash in their name, so a changed file gets a new URL and the old one can be cached for a year. Pages keep their URLs, so they don’t get that header.