How to Set Up a VPS for Beginners, from a Beginner (Part 4: Traefik and HTTPS)
Part 4 of setting up my first VPS on Debian 13. Traefik as a rootless reverse proxy, with ports handed over by systemd, automatic Let's Encrypt certificates, HTTP/3, security headers, and routes that come from container labels.
A 5-part series.
Where We Are
After Part 3, the server can run containers that survive reboots. But nothing can reach them from outside yet, and there’s no HTTPS.
That’s the reverse proxy’s job. It’s the one thing listening on ports 80 and 443. It gets certificates from Let’s Encrypt, and passes each request to the right container based on the domain.
Why Traefik
I went with Traefik because it can find apps by itself. It watches Podman, and when a container starts with a few labels saying “I’m app.example.com, I listen on port 3000”, Traefik adds the route and gets a certificate for it. When the container stops, the route goes away.
That matters for Part 5, where apps get deployed on every git push. I never want to edit a proxy config file by hand when a new app shows up.
Letting Traefik See the Containers
Traefik learns about containers through the Docker API. Podman can serve the same API, from a socket owned by my user:
systemctl --user enable --now podman.socket
ls $XDG_RUNTIME_DIR/podman/podman.sock/run/user/1000/podman/podman.sockKeep in mind that whoever can talk to this socket can control all of my containers. Traefik needs it, so further down I lock Traefik’s own container down as far as it goes.
A Network for Public Apps
Traefik and the apps it serves need a network they share. Quadlet can create networks too:
nano ~/.config/containers/systemd/web.network[Network]
NetworkName=webOnly containers that should be public join web. A database never does, so Traefik can’t reach it even by mistake.
Certificates need to survive restarts, so they get a named volume:
nano ~/.config/containers/systemd/traefik-acme.volume[Volume]
VolumeName=traefik-acmeTraefik’s Config
Traefik has two kinds of config. The static one is read once at startup: ports, where to find containers, how to get certificates.
mkdir -p ~/.config/traefik/dynamic
nano ~/.config/traefik/traefik.ymlglobal:
checkNewVersion: false
sendAnonymousUsage: false
log:
level: WARN
format: json
entryPoints:
web:
address: ':80'
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
aliasHeadersStrategy: delete
encodedCharacters:
allowEncodedBackSlash: false
allowEncodedNullCharacter: false
websecure:
address: ':443'
http3: {}
http:
tls:
certResolver: le
middlewares:
- secure@file
aliasHeadersStrategy: delete
encodedCharacters:
allowEncodedBackSlash: false
allowEncodedNullCharacter: false
providers:
docker:
endpoint: 'unix:///var/run/docker.sock'
exposedByDefault: false
network: web
watch: true
file:
directory: /etc/traefik/dynamic
watch: true
certificatesResolvers:
le:
acme:
email: 'you@example.org'
storage: /acme/acme.json
httpChallenge:
entryPoint: web
api:
dashboard: falseThe important parts:
- Two entry points.
webon port 80 only redirects to HTTPS (and answers Let’s Encrypt’s check).websecureon 443 is where everything is served, with HTTP/3 turned on. - Every HTTPS route uses the
securemiddleware, defined below. A new app can’t forget its security headers because it never has to add them. exposedByDefault: false. A container only gets a route if it asks for one withtraefik.enable=true.leis the Let’s Encrypt setup. Put your real e-mail there; Let’s Encrypt uses it to warn about certificates that are about to expire.aliasHeadersStrategyandencodedCharactersdrop a couple of tricks with odd headers and encoded characters in URLs that can confuse apps behind the proxy.- No dashboard. I don’t need it, and it’s one less thing exposed.
While testing, add caServer: 'https://acme-staging-v02.api.letsencrypt.org/directory' under acme. Let’s Encrypt has rate limits, and a few broken attempts in a row can block you for a while. The staging server gives fake certificates with no limits. Remove the line once everything works.
The dynamic config is the other kind. Traefik reloads it whenever the file changes:
nano ~/.config/traefik/dynamic/00-defaults.ymlhttp:
middlewares:
secure:
chain:
middlewares:
- security-headers
- rate-limit
- compress
security-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
contentTypeNosniff: true
customFrameOptionsValue: SAMEORIGIN
referrerPolicy: strict-origin-when-cross-origin
permissionsPolicy: 'camera=(), microphone=(), geolocation=(), payment=()'
customResponseHeaders:
Server: ''
X-Powered-By: ''
rate-limit:
rateLimit:
average: 100
burst: 200
period: 1s
compress:
compress:
minResponseBodyBytes: 1024
tls:
options:
default:
minVersion: VersionTLS12
sniStrict: true
cipherSuites:
- TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
- TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
- TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305
- TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305secure is three middlewares chained together:
- Security headers. HSTS tells browsers to only ever use HTTPS for this domain. The others stop MIME sniffing and framing by other sites, limit what the referrer leaks, and switch off camera, microphone and location access.
ServerandX-Powered-Byare removed so the response doesn’t advertise what’s running. - Rate limit. 100 requests per second per client IP, with bursts up to 200. Plenty for a person, and it slows down anything hammering the server.
- Compression for responses over 1 KB.
The tls part allows TLS 1.2 and up with modern ciphers only. sniStrict refuses connections that don’t name one of my domains, so someone scanning the bare IP gets nothing back.
Letting systemd Open the Ports
This is the part I spent the most time on. I want Traefik to see each visitor’s real IP address, because the rate limit and the logs depend on it.
So Traefik doesn’t open ports 80 and 443 at all. My user’s systemd opens them (allowed since the ip_unprivileged_port_start change in Part 3) and hands the open sockets to Traefik when it starts. Connections go straight to Traefik, with nothing in between rewriting where they came from. Traefik supports this directly: it matches each socket to an entry point by name.
nano ~/.config/systemd/user/traefik-http.socket[Unit]
Description=Traefik :80 (HTTP → HTTPS redirect, ACME challenges)
[Socket]
ListenStream=80
FileDescriptorName=web
Service=traefik.service
[Install]
WantedBy=sockets.targetnano ~/.config/systemd/user/traefik-https.socket[Unit]
Description=Traefik :443 (HTTPS over TCP, HTTP/3 over UDP)
[Socket]
ListenStream=443
ListenDatagram=443
FileDescriptorName=websecure
Service=traefik.service
[Install]
WantedBy=sockets.targetFileDescriptorName has to match the entry point names in traefik.yml, web and websecure. ListenDatagram=443 is the UDP socket for HTTP/3.
The Traefik Container
nano ~/.config/containers/systemd/traefik.container[Unit]
Description=Traefik reverse proxy (socket-activated)
Requires=traefik-http.socket traefik-https.socket podman.socket
After=traefik-http.socket traefik-https.socket podman.socket
[Container]
Image=docker.io/library/traefik:v3.7
ContainerName=traefik
Network=web.network
Notify=true
AutoUpdate=registry
NoNewPrivileges=true
ReadOnly=true
DropCapability=ALL
PidsLimit=256
PodmanArgs=--memory=256m
Volume=%t/podman/podman.sock:/var/run/docker.sock
Volume=%h/.config/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
Volume=%h/.config/traefik/dynamic:/etc/traefik/dynamic:ro
Volume=traefik-acme.volume:/acme
[Service]
Sockets=traefik-http.socket traefik-https.socket
Restart=always
TimeoutStartSec=300
[Install]
WantedBy=default.targetGoing through it:
Sockets=is where systemd passes in the two sockets from above.- The Podman socket is mounted where Traefik expects Docker’s.
%tis my user’s runtime directory and%his my home directory. - The config files are mounted read-only.
ReadOnly,DropCapability=ALL,NoNewPrivileges, and the memory and process limits lock the container down. Because it can reach the Podman socket, I want it as boxed in as possible.Notify=truemakes systemd wait until Traefik says it’s ready before counting the start as done.AutoUpdate=registrytogether withtraefik:v3.7means Podman checks every day for a newerv3.7image, which is a patch release, and restarts Traefik on it. A jump to 3.8 is still my decision.
Turn on the daily update check:
systemctl --user enable --now podman-auto-update.timerStarting It
Make sure nothing else has the ports. Some provider images come with Apache or nginx already running:
sudo ss -ltnup '( sport = :80 or sport = :443 )'That should print nothing but the header. Then check the Quadlet files, load everything, and start:
/usr/libexec/podman/quadlet -dryrun -user > /dev/null
systemctl --user daemon-reload
systemctl --user enable --now traefik-http.socket traefik-https.socket
podman pull docker.io/library/traefik:v3.7
systemctl --user start traefik
systemctl --user status traefik● traefik.service - Traefik reverse proxy (socket-activated)
Loaded: loaded (/home/admin/.config/containers/systemd/traefik.container; generated)
Active: active (running) since Tue 2026-10-14 10:12:40 WIB; 3s ago
TriggeredBy: ● traefik-https.socket
● traefik-http.socketI pulled the image before starting so the first start doesn’t sit there downloading. If it fails, the log says why:
journalctl --user -u traefik -n 30DNS
Before Let’s Encrypt can issue a certificate, the domain has to point at the server. At your DNS provider, add an A record for the domain with the server’s IP. I also added a wildcard (*.example.com), so any new subdomain I make later works without another trip to the DNS settings.
If the server has IPv6, add an AAAA record too.
Testing with a Real Domain
The same whoami container from Part 3, now with labels instead of a published port:
nano ~/.config/containers/systemd/whoami.container[Container]
ContainerName=whoami
Image=docker.io/traefik/whoami
Network=web.network
Label=traefik.enable=true
Label=traefik.http.routers.whoami.rule=Host(`whoami.example.com`)
Label=traefik.http.routers.whoami.entrypoints=websecure
Label=traefik.http.routers.whoami.tls.certresolver=le
Label=traefik.http.services.whoami.loadbalancer.server.port=80
[Install]
WantedBy=default.targetThose labels are the whole proxy configuration for this app: which domain, which entry point, get a certificate from le, and send traffic to port 80 inside the container.
systemctl --user daemon-reload
systemctl --user start whoamiGive it a few seconds to get the certificate, then from the laptop:
curl -I https://whoami.example.comHTTP/2 200
alt-svc: h3=":443"; ma=2592000
content-type: text/plain; charset=utf-8
permissions-policy: camera=(), microphone=(), geolocation=(), payment=()
referrer-policy: strict-origin-when-cross-origin
strict-transport-security: max-age=31536000; includeSubDomains
x-content-type-options: nosniff
x-frame-options: SAMEORIGINThat’s a valid certificate, all the security headers from the secure middleware, and alt-svc telling the browser HTTP/3 is available. http://whoami.example.com redirects to HTTPS.
And the real IP check. Without -I, whoami prints the request it received:
curl -s https://whoami.example.com | grep X-Real-IpX-Real-Ip: 203.0.113.41That’s my laptop’s public address, not some internal container address. The socket handoff works.
If the certificate doesn’t come, it’s almost always DNS not pointing at the server yet, or port 80 blocked. Let’s Encrypt checks over plain HTTP on port 80. journalctl --user -u traefik shows the exact error.
Clean up the test:
systemctl --user stop whoami
rm ~/.config/containers/systemd/whoami.container
systemctl --user daemon-reloadWhere the Server Stands
Traefik owns ports 80 and 443 through systemd and sees real client IPs. Certificates are automatic, HTTP/3 is on, every site gets the same security headers and rate limit, and a new app only needs a few labels to go live. Patch updates of Traefik install themselves.
What’s left is getting my own apps onto the server without SSH-ing in to copy files around. That’s Part 5: deploying from GitHub on every push, and the script that does all five parts at once.