A 5-part series. Part 1 — Securing the Base System · Part 2 — Firewall & Bans · Part 3 — Rootless Podman · Part 4 — Caddy & HTTPS · Part 5 — Deploying SvelteKit
Where This Continues From
Part 4 left the server serving valid HTTPS on a real domain and returning a 502, because there was nothing behind the proxy. This part puts something there.
The Adapter
SvelteKit needs to build to a standalone Node server, which means adapter-node:
npm install -D @sveltejs/adapter-nodeimport adapter from '@sveltejs/adapter-node'
export default {
kit: {
adapter: adapter()
}
}npm run build now produces a build/ directory you start with node build. It listens on port 3000 by default and is configured entirely through environment variables, which suits containers well.
The Variable That Breaks Forms
One SvelteKit-behind-a-proxy detail that will otherwise cost you an afternoon.
adapter-node needs to know its public URL in order to validate form submissions. Behind a reverse proxy it cannot work this out — it sees an internal request arriving on port 3000, not https://r3p.dev. Without it, every form action fails with:
Cross-site POST form submissions are forbiddenThe page itself loads perfectly. Only submissions break, which makes it look like an application bug rather than a deployment one. That is why it takes so long to find.
The fix is a single environment variable, set in the container unit below:
ORIGIN=https://r3p.devThe Containerfile
nano ~/apps/r3p.dev/Containerfile# ---- build stage ----
FROM docker.io/library/node:24-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --omit=dev
# ---- runtime stage ----
FROM docker.io/library/node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["node", "build"]The two stages exist for a reason worth understanding, because it is the difference between a 200 MB image and a 900 MB one.
The build stage needs the full toolchain — dev dependencies, TypeScript, Vite, the entire SvelteKit compiler. The runtime stage needs almost none of it: just Node, the compiled build/ output, and production dependencies. By copying only those three things across the stage boundary, everything used to produce the build is discarded. npm prune --omit=dev strips dev dependencies just before that copy.
COPY package*.json before COPY . . is a layer caching trick, and it is not cosmetic. Podman caches each instruction and reuses layers whose inputs have not changed. Dependencies change rarely; source changes constantly. In this order, a source-only edit reuses the cached npm ci layer. Reverse those two lines and every single deploy reinstalls every dependency from scratch.
Add a .dockerignore next to it — Podman honours the file — so local junk never enters the build context:
node_modules
.svelte-kit
build
.git
.envCopying a local node_modules into a build is both slow and a reliable source of platform-mismatch bugs when your laptop and the server differ.
Getting the Source Onto the Server
For a public repository:
cd ~/apps/r3p.dev
git clone https://github.com/username/repo.git srcFor a private one, generate a read-only deploy key on the server:
ssh-keygen -t ed25519 -C "deploy-r3p" -f ~/.ssh/deploy_r3p
cat ~/.ssh/deploy_r3p.pubAdd that public key to the repository’s Deploy Keys with write access left off. This is meaningfully better than copying your personal SSH key onto the server: it grants read access to exactly one repository, and revoking it later affects nothing else you own.
Tell SSH to use it for that host:
nano ~/.ssh/configHost github-r3p
HostName github.com
User git
IdentityFile ~/.ssh/deploy_r3p
IdentitiesOnly yesgit clone git@github-r3p:username/repo.git srcBuilding the Image
cd ~/apps/r3p.dev
podman build -t localhost/r3p:latest -f Containerfile srcThe localhost/ prefix marks it as a local image, so Podman knows not to try pulling it from a registry.
This is the step that needs the swap file from Part 2. A SvelteKit build on a 1 GB VPS is precisely the workload that gets OOM-killed without it, and the error you get says nothing about memory.
Confirm it exists:
podman imagesThe Application Unit
nano ~/.config/containers/systemd/r3p.container[Unit]
Description=r3p.dev SvelteKit application
Wants=network-online.target
After=network-online.target
[Container]
ContainerName=r3p
Image=localhost/r3p:latest
AutoUpdate=local
Network=apps.network
Environment=NODE_ENV=production
Environment=HOST=0.0.0.0
Environment=PORT=3000
Environment=ORIGIN=https://r3p.dev
[Service]
Restart=always
[Install]
WantedBy=default.targetThe most important line in this file is one that is not in it: there is no PublishPort.
This container binds nothing on the host. It is reachable only from the apps network — which means only from Caddy. Not from the internet, not from curl on the host, not from a port someone forgot to firewall. There is no host port to reach, so there is nothing to secure.
HOST=0.0.0.0 makes the app listen on all interfaces inside its own network namespace. Binding to localhost only would make it unreachable even from Caddy, since they are in different namespaces. This is not the same thing as exposing it to the world — see the previous paragraph.
AutoUpdate=local because this image is built locally rather than pulled.
A question I had here: should the process run as a non-root user inside the container? The node image runs as root by default, which would normally deserve a User=node line. Under rootless Podman, container root is already mapped to the unprivileged admin user on the host — it has no host privileges to escalate into. The usual reason for that hardening is largely already handled by the runtime. That is the payoff of everything Part 3 set up.
Starting It
systemctl --user daemon-reload
systemctl --user start r3p
systemctl --user status r3pVerify from inside the network. Since there is no host port, this is the only way to reach it directly:
podman exec caddy wget -qO- http://r3p:3000 | head -20HTML back means name resolution and networking both work. If it fails, either the container name and the Caddyfile target have drifted apart, or one of them is not on the apps network:
podman network inspect appsThen reload Caddy so it picks up the now-existing backend:
podman exec caddy caddy reload --config /etc/caddy/Caddyfilereload applies config changes with zero downtime — no restart, no dropped connections. Use it instead of systemctl --user restart caddy for every Caddyfile edit.
Now:
curl -I https://r3p.devHTTP/2 200. The site is live.
Proving It Runs Unattended
The step it is tempting to skip. Do not — this is what separates “it works” from “it works when I am asleep.”
sudo rebootReconnect and check:
systemctl --user status caddy r3p
podman ps
curl -I https://r3p.devIf both containers came back on their own, the setup is genuinely finished. If they did not, it is almost certainly lingering:
loginctl show-user admin --property=LingerLinger=no means user services stopped when your session ended and never restarted at boot. Fix it with sudo loginctl enable-linger admin and reboot again.
Then test the other failure mode — a crash rather than a reboot:
podman kill r3p
sleep 5
systemctl --user status r3pRestart=always should have brought it straight back.
Deploying Updates
The workflow after all this is short enough to script:
nano ~/apps/r3p.dev/deploy.sh#!/usr/bin/env bash
set -euo pipefail
APP_DIR="/home/admin/apps/r3p.dev"
cd "$APP_DIR/src"
git pull --ff-only
podman build -t localhost/r3p:latest -f "$APP_DIR/Containerfile" .
systemctl --user restart r3p
podman image prune -f
echo "deployed: $(git rev-parse --short HEAD)"chmod +x ~/apps/r3p.dev/deploy.shDeploying is now one command:
~/apps/r3p.dev/deploy.shset -euo pipefail makes it stop at the first failure rather than pushing on after a failed git pull and cheerfully restarting into a stale image. --ff-only refuses to create a merge commit if local and remote have diverged — on a deploy checkout, divergence means something is wrong and you want to hear about it rather than have it silently resolved.
podman image prune -f clears the dangling layers each build leaves behind. Skip it and disk usage climbs quietly until something fails for reasons that look entirely unrelated.
There is a few seconds of downtime during the restart. Zero-downtime deploys are possible — build the new image, start a second container, switch Caddy’s upstream, retire the old one — but that is real added complexity, and for a personal site a two-second gap is not worth it.
Adding a Second Project
This is where the structure pays off. Three steps.
Build the image:
mkdir -p ~/apps/blog.r3p.dev
cd ~/apps/blog.r3p.dev
git clone https://github.com/username/blog.git src
cp ~/apps/r3p.dev/Containerfile .
podman build -t localhost/blog:latest -f Containerfile srcWrite the unit — identical to the first, with three names changed:
[Unit]
Description=blog.r3p.dev application
Wants=network-online.target
After=network-online.target
[Container]
ContainerName=blog
Image=localhost/blog:latest
AutoUpdate=local
Network=apps.network
Environment=NODE_ENV=production
Environment=HOST=0.0.0.0
Environment=PORT=3000
Environment=ORIGIN=https://blog.r3p.dev
[Service]
Restart=always
[Install]
WantedBy=default.targetsystemctl --user daemon-reload
systemctl --user start blogAdd the domain to Caddy:
blog.r3p.dev {
encode zstd gzip
reverse_proxy blog:3000
}podman exec caddy caddy reload --config /etc/caddy/CaddyfilePoint DNS at the server and Caddy issues the certificate on the first request.
Both apps use port 3000 and nothing collides. No second proxy, no port registry to maintain, no system file to edit. The Caddyfile is the single place that knows which domain maps to which container.
Keeping It Running
Logs. Everything is in the journal, because everything is a systemd service:
journalctl --user -u r3p -f
journalctl --user -u caddy --since "1 hour ago"
journalctl --user -u r3p -p errPatching Caddy. AutoUpdate=registry on the Caddy unit lets Podman pull newer images automatically. Turn on the timer:
systemctl --user enable --now podman-auto-update.timer
podman auto-update --dry-runI enable this for Caddy specifically — it is the container facing the internet, so I want it patched without waiting for me to notice. The application containers use AutoUpdate=local and only change when I deploy. I do not want my own code updating itself behind my back.
Backups. The entire deployment is two directories:
tar czf backup-$(date +%F).tar.gz \
-C /home/admin apps .config/containers/systemdApplication source lives in git, so that tarball is really just configuration plus Caddy’s certificate data. It restores onto a fresh server by copying it back, running daemon-reload, and rebuilding the images.
That is the property I was aiming for from the very start — nothing important living in a system path I would forget to copy.
Commands worth remembering:
# after editing any unit file — always
systemctl --user daemon-reload
# state
systemctl --user status caddy r3p
podman ps
# logs
journalctl --user -u r3p -f
# apply a Caddyfile change with no downtime
podman exec caddy caddy reload --config /etc/caddy/Caddyfile
# deploy
~/apps/r3p.dev/deploy.sh
# disk
podman system df
podman image prune -fThings That Went Wrong
Every one of these cost me real time.
Edited a .container file, nothing changed. No daemon-reload. The generator only re-reads unit files when systemd reloads.
Containers died on disconnect. Lingering not enabled. Everything looks perfect while you are still logged in.
no such host: r3p. Containers on different networks, or aardvark-dns not installed. Both being “up” tells you nothing about whether they can see each other.
ACME rate limit. Restarted Caddy repeatedly while debugging without a persistent /data. Fresh certificate request every time, then a week-long lockout for that domain.
Forms rejected with “Cross-site POST form submissions are forbidden”. ORIGIN not set. The site looks completely fine until someone submits something.
Build killed with no error. Out of memory, no swap. dmesg | grep -i "killed process" is where the real reason lives.
Failed to connect to bus. Ran systemctl --user through sudo -u admin. SSH in as the user instead.
Closing
Looking back across all five parts, what changed was less about the tools than about how much of the system I could hold in my head.
The setup that ended up working is small: SSH keys, a default-deny firewall, a handful of unit files, one Caddyfile, one Containerfile per app. No daemon running as root. No orchestration layer. No configuration scattered across /etc that I will have forgotten about in six months. The whole deployment fits in one user’s home directory, and I can explain every line of it.
That last part is what I would tell myself at the start. A setup you fully understand is worth more than a sophisticated one you copied. When something breaks at an inconvenient hour — and it will — the only thing that helps is having a clear model of how the pieces fit together.
The server is not finished and never will be. There is monitoring to add, backups to automate, a staging environment worth having. But it runs, it comes back after a reboot, and it stays up without me watching it.
For a first VPS, that was the whole goal.
If you want to go deeper on the two pieces doing the heavy lifting here, I wrote them up on their own: Why I Chose Podman Over Docker, Podman Quadlet: Running Containers as systemd Services, and Caddy as a Reverse Proxy.