What you will set up
Podman runs containers much like Docker, but without a background service running as root. Here, containers run as ordinary users, and each app gets a user of its own:
- Root inside a container is that app's user outside it, so someone who breaks out of a container gets no more rights on the server than that user has.
- Each user has its own range of user IDs, so the containers of one app cannot read the files of another.
Only one app can own ports 80 and 443, so they go to Caddy, a web server with its own user. Caddy gets HTTPS certificates for every site on the server and passes each visitor on to the right app, which listens on a local port that cannot be reached from the internet.
The app guides build on this one: Nextcloud, Matrix, Vaultwarden, Actual Budget, Stalwart, a mail server, Forgejo, a Git server, Helium services, for the Helium browser, Headscale, a Tailscale control server, Hermes Agent, an AI agent, FreeRADIUS, for Wi-Fi logins, authentik, one login for all your apps, ERPNext, for accounting and invoicing, Zabbix, to monitor your servers, Uptime Kuma, to watch your sites, Linkwarden, for bookmarks, Immich, for photos, Home Assistant, for your smart home, AdGuard Home, to block ads and trackers, Minecraft, a game server, Portainer, a web UI for your containers, OpenClaw, an AI assistant, Plausible and Umami, for web analytics, n8n, for automation, Paperless-ngx, for documents, Jellyfin and Navidrome, for films and music, SearXNG, a private search engine, Open WebUI, an AI chat, BookStack, a wiki, Stirling-PDF, for PDF tools, ntfy, for push notifications, Garage, for S3 storage, Prometheus and Grafana, for monitoring, RustDesk, for remote desktop, and k3s, a small Kubernetes that runs without root behind the same Caddy. Every step below was run on a fresh Melonslab server with Debian 13.
Before you start
You need a server with Debian 13 and root access. If it is new, secure it first.
1. Install Podman
apt update
apt install -y podman systemd-container
Podman comes with pasta, which connects rootless containers to the network. systemd-container provides machinectl, which you will use to work as each app's user.
2. Allow ports 80 and 443
Linux reserves the ports below 1024 for root. Let ordinary users use them from port 80 upwards, so that Caddy can:
echo "net.ipv4.ip_unprivileged_port_start = 80" > /etc/sysctl.d/50-unprivileged-ports.conf
sysctl --system
The setting covers IPv6 too.
3. Create a user for Caddy
Every app starts the same way, with a user of its own. For Caddy:
useradd -m -s /bin/bash caddy
loginctl enable-linger caddy
Debian gives each new user its own range of 65,536 extra user IDs, which Podman uses for the users inside its containers; cat /etc/subuid lists every user's range. enable-linger starts the user's services at boot, without anyone logging in.
4. Work as the user
machinectl shell caddy@
machinectl shell starts a proper login session for the user, which systemctl --user and Podman need. exit takes you back to root.
5. Run Caddy as a service
Podman's Quadlet turns a short file describing a container into a systemd service. As caddy, create the directory for these files:
mkdir -p ~/.config/containers/systemd
Create ~/.config/containers/systemd/caddy.container:
[Unit]
Description=Caddy, the web server in front of every app
[Container]
ContainerName=caddy
Image=docker.io/library/caddy:2
Network=host
Volume=%h/Caddyfile:/etc/caddy/Caddyfile:ro
Volume=caddy-data:/data
AutoUpdate=registry
[Service]
Restart=always
[Install]
WantedBy=default.target
And ~/Caddyfile, Caddy's configuration:
{
admin off
}
# One block per site. The app guides add theirs below.
:80 {
respond "Caddy is running"
}
Start it:
systemctl --user daemon-reload
systemctl --user start caddy
Open http://203.0.113.10 (your server's address) in a browser. "Caddy is running" means it is serving port 80 as the caddy user.
What the lines do:
Network=hostputs Caddy on the server's own network, so it sees every visitor's real IP address and can reach the apps on127.0.0.1. It is the one container that faces the internet.admin offswitches off Caddy's admin interface, which would otherwise let any user on the server change its configuration. Changes to the Caddyfile then take effect when you restart Caddy.Imageis written in full, with the registry, so there is no doubt where the image comes from.AutoUpdate=registrylets Podman update the container when a newer image is published (step 6).WantedBy=default.targetstarts it at boot. Services made from Quadlet files cannot be switched on withsystemctl enable; this line does that instead.
6. Keep containers up to date
systemctl --user enable --now podman-auto-update.timer
Once a day, Podman checks for a newer image for every container with AutoUpdate=registry and restarts the ones that changed. If a container fails to start on the new image, Podman goes back to the previous one. The timer is per user, so switch it on as every app's user. To see what it would update right now:
podman auto-update --dry-run
7. How an app fits in
Each app guide follows the same pattern:
- As root, create the app's user with the two lines from step 3, and switch to it with
machinectl shell. - As that user, run the app, in a pod or as a single container, publishing one port on
127.0.0.1only, such asPublishPort=127.0.0.1:8081:80. The guides give each app its own port. - As
caddy, add a block for the app's address to~/Caddyfileand restart Caddy withsystemctl --user restart caddy. Caddy gets the certificate by itself.
Two things apply to every app behind Caddy:
- Inside the app's pod or container, Caddy's connections appear to come from the server's own IPv4 address,
203.0.113.10in these examples. That is how pasta passes on local connections, so it is the address the app should trust to forward the visitor's real one. - Inside the pod, the server's own address belongs to the pod itself, so an app that calls its own public address would reach itself instead of Caddy.
AddHost=cloud.example.com:host-gatewayin the pod file sends that name to the server instead.
8. Everyday commands
As the user that owns the container:
podman ps # running containers
systemctl --user status caddy # the service
journalctl --user -u caddy # its log
systemctl --user restart caddy # restart it
Troubleshooting
systemctl --user says "Failed to connect to user scope bus". You switched user with su or sudo, which does not start a login session. Use machinectl shell caddy@ from step 4.
Caddy does not start, and its log says "bind: permission denied". Step 2 is missing or has not been loaded; sysctl net.ipv4.ip_unprivileged_port_start should print 80. After fixing it, systemctl --user reset-failed caddy lets systemd start Caddy again, as it stops retrying after a few failures.
Every visitor has the same IP address in the app. Check that the app trusts the server's own IPv4 address as its proxy (step 7). Containers attached to a Podman network (Network= in the file) are worse still: their ports go through a forwarder that replaces the visitor's address with its own, which is why the app guides use pods instead.
apt waits for a lock right after the server's first boot. The server is installing its first updates. Wait a minute and try again.