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:00 with 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.timer handles 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.