How to Set Up a VPS for Beginners, from a Beginner (Part 4: Caddy, HTTPS, and the Private Network)

Part 4 of a journey learning how I set up my first VPS on Debian 13 Minimal. Putting Caddy in front as a reverse proxy with automatic HTTPS - a private container network, persistent certificate storage, and a Quadlet unit that survives reboots.

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 3 got rootless Podman working and proved a container could survive a reboot as a systemd service. What is still missing is everything between the internet and that container: a public domain, TLS, and something that decides which request goes where.

That is this part. By the end, HTTPS will be live on a real domain — returning an error, because there is no application yet, but a correctly encrypted error.

What We Are Building

The shape matters more than any individual command:

                 Internet
                     │
              :80 / :443
                     ▼
          ┌────────────────────┐
          │       Caddy        │   ← the only container with published ports
          │  automatic HTTPS   │
          │   reverse proxy    │
          └─────────┬──────────┘
                    │  private container network "apps"
        ┌───────────┴────────────┐
        ▼                        ▼
  ┌───────────┐            ┌───────────┐
  │    r3p    │            │   blog    │
  │  :3000    │            │  :3000    │
  └───────────┘            └───────────┘
     no published ports       no published ports

Two properties are doing most of the work here.

Only Caddy is exposed. The application containers publish nothing to the host. They are reachable exclusively from inside the apps network. Even if the firewall were misconfigured, there is no host port that reaches them. That is not a rule I have to keep enforcing — it is simply the topology.

Both apps listen on 3000. That would be a collision between host processes. Between containers on the same network it is not, because each has its own network namespace and Caddy addresses them by name. No port bookkeeping, no remembering that the blog is on 3001 and the API on 3002.

Why Caddy

Caddy is here instead of Nginx for one reason that outweighs everything else: it obtains and renews TLS certificates by itself.

No certbot. No renewal cron job. No ACME challenge directory to configure, no .well-known alias to get right, no separate HTTP block that exists only so a renewal can succeed. You write a domain name in the config, and if DNS points at the server, HTTPS exists — with a redirect from HTTP, HTTP/2, and OCSP stapling, none of which you configured.

For a first server, deleting certificate management entirely is a large amount of complexity that never has to be learned. Nginx is a superb piece of software and is faster at serving static files at scale, but almost none of that matters for a personal site, and the config is several times longer for the same outcome. I went into the comparison properly in Caddy as a Reverse Proxy, including where Nginx still wins.

The Folder Layout

Building on ~/apps from Part 3:

/home/admin/apps
└── caddy
    └── Caddyfile

That is genuinely all of it. There is no directory for the application, because in Part 5 the app is built by GitHub Actions and pulled from a registry as a finished image — the server never holds its source.

And separately, the units:

/home/admin/.config/containers/systemd
├── apps.network
├── caddy-data.volume
├── caddy-config.volume
├── caddy.container
└── r3p.container

The split is deliberate. ~/apps holds content — source code, config files, things I edit by hand. ~/.config/containers/systemd holds declarations — how those things run. Two different kinds of thing, kept apart, both inside one home directory that a single backup captures.

mkdir -p ~/apps/caddy

Throughout, I use admin as the username and r3p.dev as the domain, carried over from Part 1. Substitute your own. Note also that Quadlet is not consistent about expanding %h in every field, so I write absolute paths like /home/admin/... rather than relying on specifiers.

The Shared Network

Everything is joined by a private container network. Without one, containers cannot resolve each other by name — this is what aardvark-dns from Part 3 was for.

nano ~/.config/containers/systemd/apps.network
[Unit]
Description=Shared network for web applications

[Network]
NetworkName=apps

That is the whole file. Quadlet creates the network on demand, and any container referencing it gets an automatic dependency on it, so start ordering takes care of itself.

NetworkName=apps is worth setting explicitly. Without it, Quadlet prefixes the unit name and you get a network called systemd-apps — which is then what you must type in every debugging command for the rest of the project’s life.

Persistent Certificate Storage

Two named volumes. These matter far more than they look:

nano ~/.config/containers/systemd/caddy-data.volume
[Volume]
VolumeName=caddy-data
nano ~/.config/containers/systemd/caddy-config.volume
[Volume]
VolumeName=caddy-config

/data is where Caddy keeps issued certificates, private keys, and ACME account state. If that is not persistent, every container restart throws the certificates away and requests new ones.

Let’s Encrypt enforces rate limits — five duplicate certificates per week — and you can burn through that faster than you would expect while debugging something unrelated. Then you are locked out of issuing for that domain for days, with a working server and no way to get a certificate onto it.

This is the one mistake in this whole series with a real cooldown penalty. Persist /data.

/config holds Caddy’s autosaved runtime config and internal state. Less critical, but there is no reason not to keep it.

The Caddyfile

nano ~/apps/caddy/Caddyfile
{
	email you@example.com
}

r3p.dev, www.r3p.dev {
	encode zstd gzip
	reverse_proxy r3p:3000
}

Small file, several things happening.

The global block at the top sets the ACME contact address. Let’s Encrypt uses it to warn you if renewal ever starts failing. Use an address you actually read.

reverse_proxy r3p:3000 is the line that makes the architecture work. r3p is not a hostname configured anywhere — it is the container’s name, resolved by aardvark-dns on the apps network. Rebuild or replace that container and this line does not move, as long as the name holds.

encode zstd gzip compresses responses, negotiating whichever the client supports.

What is absent is as notable as what is present. No certificate paths. No listen 443 ssl. No port 80 redirect block. No TLS ciphersuite list. No ACME challenge location. Caddy does automatic HTTPS, HTTP→HTTPS redirects, HTTP/2, and OCSP stapling with zero configuration.

You will also notice there is no /srv volume anywhere. Most Caddy examples mount one, because most examples serve static files. Everything here is a reverse proxy to a Node process, so Caddy never reads a file from disk except this config.

The Caddy Container Unit

nano ~/.config/containers/systemd/caddy.container
[Unit]
Description=Caddy reverse proxy
Wants=network-online.target
After=network-online.target

[Container]
ContainerName=caddy
Image=docker.io/library/caddy:2-alpine
AutoUpdate=registry
Network=apps.network

PublishPort=80:80
PublishPort=443:443
PublishPort=443:443/udp

Volume=/home/admin/apps/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
Volume=caddy-data.volume:/data
Volume=caddy-config.volume:/config

[Service]
Restart=always
TimeoutStartSec=300

[Install]
WantedBy=default.target

Reading through it:

Network=apps.network refers to the unit file, not the network name. Quadlet resolves it and adds the dependency automatically.

PublishPort — the third one, 443:443/udp, is HTTP/3 over QUIC. This is why Part 2 opened UDP 443 in the firewall. Omit it and everything still works, minus HTTP/3.

The Caddyfile is mounted read-only. The container has no business modifying its own configuration.

Volume syntax is HOST:CONTAINER — left is outside, right is inside. This confused me longer than anything else when I started. /etc/caddy on the right is a path inside the container’s filesystem. It has nothing to do with /etc/caddy on the VPS, which does not exist here and never will. Nothing in this setup writes to a system directory. Once that clicked, container configuration stopped feeling mysterious.

Restart=always goes in [Service], not [Container]. This is a real systemd unit, so systemd directives live in systemd sections.

TimeoutStartSec=300 gives the first start room to pull the image and complete the ACME challenge before systemd decides it has hung.

[Install] WantedBy=default.target is what makes it start at boot.

Point DNS at the Server First

Before starting Caddy, the domain must resolve to your VPS. At your DNS provider:

A     r3p.dev       →  YOUR_VPS_IP
A     www.r3p.dev   →  YOUR_VPS_IP

Then verify from the server, not from your laptop:

dig +short r3p.dev

If that does not print your VPS IP, wait for propagation. Starting Caddy against DNS that has not propagated means a failed ACME challenge, and repeated failures count against those rate limits. Check first — it costs ten seconds.

Starting It

systemctl --user daemon-reload
systemctl --user start caddy

The reload is what triggers the Quadlet generator to read the new unit files. Same loop as Part 3, and the same thing everyone forgets.

Watch it work:

systemctl --user status caddy
journalctl --user -u caddy -f

In the logs you are looking for certificate acquisition:

certificate obtained successfully

Then, from anywhere:

curl -I https://r3p.dev

A 502 here is success. It means Caddy is serving valid HTTPS on your domain and dutifully trying to reach a backend that does not exist yet. Which is exactly where we are.

If instead the certificate never arrives, the usual causes are DNS not resolving to this server, port 80 blocked at the firewall (Let’s Encrypt validates over HTTP first), or a cloud provider security group sitting in front of ufw that you forgot about.

Where the Server Stands

There is now a public HTTPS endpoint on a real domain, with certificates that renew themselves and survive restarts, in front of a private network where applications can talk to each other by name without exposing a single port.

Everything from here is adding backends behind it.

Next Part

In Part 5, a real SvelteKit application goes behind that proxy — the Dockerfile, the one environment variable that breaks form submissions if you miss it, a GitHub Actions pipeline that builds to GHCR so the server never compiles anything, and the reboot test that proves the whole thing runs unattended.

© 2026 r3p.dev. All rights reserved.