Rootless Podman + Caddy on Rocky Linux (Hetzner)
A minimal, GDPR-friendly hosting setup with Podman, Caddy, and nftables on Rocky Linux 10.
1. Overview
This guide shows how I host sebidev.com on a Hetzner Cloud VM using:
- Rocky Linux 10 (minimal)
- Podman (rootless) +
podman-compose - Caddy 2 (automatic HTTPS via Let’s Encrypt ECDSA)
- nftables (simple L3/L4 flood limits)
- dnf-automatic (unattended security updates)
Design goals: simple, safe, cheap, EU-hosted, no tracking, no cookies.
2. Base system & user
# as root
dnf update -y
dnf install -y epel-release
dnf install -y podman podman-compose nftables htop curl git tmux fastfetch dnf-automatic slirp4netns fuse-overlayfs shadow-utils
Create your normal user for podman rootless
useradd -m sebidev
passwd sebidev
usermod -aG wheel sebidev
3. SSH hardening (only keys)
# add your public key
mkdir -p /home/sebidev/.ssh
nano /home/sebidev/.ssh/authorized_keys
chmod 700 /home/sebidev/.ssh
chmod 600 /home/sebidev/.ssh/authorized_keys
# enforce key-only in /etc/ssh/sshd_config (or 99-override in sshd_config.d)
PasswordAuthentication no
PermitRootLogin no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
UsePAM no
systemctl restart sshd
4. Automatic updates
systemctl enable --now dnf-automatic-install.timer
systemctl start dnf-automatic-install.timer
systemctl status dnf-automatic-install
systemctl list-timers | grep dnf-automatic
Timer runs daily (default
OnCalendar=*-*-* 6:00with randomized delay).
5. Rootless Podman (privileged ports)
Allow non-root processes to bind port 80 on IPv4 (kernel allows this tunable; IPv6 doesn’t have an equivalent):
echo "net.ipv4.ip_unprivileged_port_start=80" | tee /etc/sysctl.d/90-podman-ports.conf
sysctl --system
For IPv6, use Caddy’s listener on 80/443 normally (rootless Podman publishes from the host).
6. Site checkout & Caddy config
# switch to your user
su - sebidev
# workspace
mkdir -p ~/caddy/srv
cd ~/caddy
# fetch the site
git clone https://github.com/Sebidev/sebidev.com.git srv
Caddyfile
:80, :443 {
root * /srv
encode gzip
file_server
try_files {path}.html {path}/index.html {path}
handle_errors {
@404 expression {http.error.status_code} == 404
rewrite * /404.html
file_server
}
# Automatic HTTPS by Caddy (Let's Encrypt; ECDSA intermediate like "E8")
}
podman-compose.yml
version: "3.8"
services:
caddy:
image: docker.io/library/caddy:2-alpine
container_name: caddy
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./srv:/srv:ro
ports:
- "80:80"
- "443:443"
restart: unless-stopped
Start it:
cd ~/caddy
podman-compose up -d
7. Autostart (manual systemd user unit)
podman-compose systemd on Rocky didn’t generate a unit, so we wrote it manually.
Create ~/.config/systemd/user/podman-compose@caddy.service:
[Unit]
Description=Rootless Podman Compose stack for %I
After=network-online.target
Wants=network-online.target
[Service]
Restart=always
ExecStart=/usr/bin/podman-compose -f /home/sebidev/caddy/podman-compose.yml up
ExecStop=/usr/bin/podman-compose -f /home/sebidev/caddy/podman-compose.yml down
WorkingDirectory=/home/sebidev/caddy
TimeoutStopSec=30
KillMode=control-group
[Install]
WantedBy=default.target
Enable lingering (so user services start at boot without a login session):
systemctl --user daemon-reload
sudo loginctl enable-linger sebidev
systemctl --user enable --now podman-compose@caddy
systemctl --user status podman-compose@caddy
8. nftables (simple L3/L4 flood limits)
This variant matches what runs cleanly on Rocky 10’s nft parser (accept SSH + HTTP/S, rate-limit pings & new connections):
#!/usr/sbin/nft -f
flush ruleset
define SSH_PORT = 22
define HTTP_PORTS = {80, 443}
table inet filter {
chain input {
type filter hook input priority 0; policy drop;
iif lo accept
ct state established,related accept
ct state invalid drop
ip protocol icmp icmp type echo-request limit rate 10/second burst 5 packets accept
ip6 nexthdr ipv6-icmp icmpv6 type echo-request limit rate 10/second burst 5 packets accept
tcp dport 22 ct state new limit rate 5/minute burst 5 packets accept
tcp dport { 80, 443 } ct state new limit rate 200/second burst 5 packets accept
udp dport { 53, 67, 68, 123 } limit rate 200/second burst 5 packets accept
limit rate 5/minute burst 5 packets log prefix "DROP input: "
drop
}
chain forward { type filter hook forward priority 0; policy drop; }
chain output { type filter hook output priority 0; policy accept; }
}
Enable & persist:
sudo systemctl enable --now nftables
nft list ruleset > /etc/nftables.conf
9. Operational notes
-
Updates:
dnf-automatic-install.timerhandles daily security updates. -
Service management (user):
systemctl --user restart podman-compose@caddy
journalctl --user -fu podman-compose@caddy -
Logs & privacy:
I don’t persist access logs; only transient in-RAM processing (IP/User-Agent) to serve the site and provide L3/4 protection.
No analytics, no cookies. -
GDPR:
EU hosting (Hetzner), no tracking, no data storage → essentially “cookie-banner free.”
The privacy page explains transient processing and provider role.
10. Why this stack
| Feature | Tool / Configuration |
|---|---|
| OS | Rocky Linux 10 |
| Containers | Podman (rootless) |
| Reverse Proxy | Caddy 2 (auto HTTPS ECDSA) |
| Firewall | nftables |
| Auto Updates | dnf-automatic |
| Hosting | Hetzner Cloud VM |
| GDPR Status | ~99% compliant – no tracking, no cookies |
Appendix: Useful one-liners
# Update site content
cd ~/caddy/srv && git pull && cd .. && podman-compose restart
# See open service ports
ss -ltpn
# Show current nft rules
sudo nft list ruleset
Summary:
Rootless Podman + Caddy + nftables = a perfect small, private, and maintainable web stack.
Simple to deploy, rock-solid, and GDPR-clean — ideal for personal projects or small hosting nodes.