How to Set Up a VPS for Beginners, from a Beginner (Part 5: Deploying a SvelteKit App)

The final part of setting up my first VPS on Debian 13 Minimal. Containerizing a SvelteKit app with adapter-node, running it as a Quadlet service behind Caddy with no published ports, a one-command redeploy script, and the failures that cost me the most time.

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-node
import 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 forbidden

The 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.dev

The 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
.env

Copying 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 src

For 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.pub

Add 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/config
Host github-r3p
	HostName github.com
	User git
	IdentityFile ~/.ssh/deploy_r3p
	IdentitiesOnly yes
git clone git@github-r3p:username/repo.git src

Building the Image

cd ~/apps/r3p.dev
podman build -t localhost/r3p:latest -f Containerfile src

The 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 images

The 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.target

The 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 r3p

Verify 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 -20

HTML 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 apps

Then reload Caddy so it picks up the now-existing backend:

podman exec caddy caddy reload --config /etc/caddy/Caddyfile

reload 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.dev

HTTP/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 reboot

Reconnect and check:

systemctl --user status caddy r3p
podman ps
curl -I https://r3p.dev

If 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=Linger

Linger=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 r3p

Restart=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.sh

Deploying is now one command:

~/apps/r3p.dev/deploy.sh

set -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 src

Write 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.target
systemctl --user daemon-reload
systemctl --user start blog

Add the domain to Caddy:

blog.r3p.dev {
	encode zstd gzip
	reverse_proxy blog:3000
}
podman exec caddy caddy reload --config /etc/caddy/Caddyfile

Point 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 err

Patching 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-run

I 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/systemd

Application 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 -f

Things 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.

© 2026 r3p.dev. All rights reserved.