How to Set Up a VPS for Beginners, from a Beginner (Part 3: Rootless Podman and the First Container)
Part 3 of setting up my first VPS on Debian 13. Rootless Podman owned by one user, UID mapping, cgroup delegation, lingering, and running a first container as a systemd service with Quadlet.
A 5-part series.
Where We Are
Part 1 secured the login. Part 2 closed the ports and made the server patch itself.
The server is safe now, and completely useless, because nothing runs on it. This part gets containers running.
Why Containers, and Why Podman
For one small app, I could install Node, clone the repo and run it as a systemd service. That works until the second app needs a different Node version. With containers, every app brings its own dependencies, and removing an app removes all of it. The same image also runs the same way on my laptop and on the server.
I picked Podman over Docker for this server for three reasons:
- No daemon. Docker runs one background service as root that owns every container. Podman doesn’t have one; containers are just processes.
- Rootless is the normal way to use it. Containers run as my normal user. Root inside a container is not root on the server.
- It fits systemd. Debian already runs systemd, which handles starting things at boot, restarting them when they crash, and collecting logs. With Quadlet (below), a container is just another systemd service.
The commands are the same as Docker’s (podman run, podman ps, podman build) and it uses the same images, so there wasn’t much to relearn.
Installing Podman
sudo apt install -y podman passt crun netavark aardvark-dns catatonit uidmap dbus-user-session systemd-containerMost of these get pulled in anyway, but I’d rather list them so I know what’s there:
passtprovidespasta, the networking Podman uses for rootless containers.netavarkcreates container networks, andaardvark-dnslets containers on the same network find each other by name. Part 4 depends on that.uidmaphas the helpers that make rootless work at all (more on that next).dbus-user-sessiongives my user a proper session bus, which the per-user systemd needs.systemd-containergives memachinectl, which I use below.
Checking the UID Mapping
Rootless containers work by mapping a range of extra user IDs to my user. A process that thinks it’s root (UID 0) inside the container is really some high, unprivileged UID on the server. That range has to be reserved for the user. useradd on Debian does it automatically, but check anyway:
grep admin /etc/subuid /etc/subgid/etc/subuid:admin:100000:65536
/etc/subgid:admin:100000:65536Start at 100000, 65536 of them. If either file has no line for your user, add one:
sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 adminThen a quick test as admin:
podman run --rm docker.io/library/alpine echo "rootless works"Trying to pull docker.io/library/alpine:latest...
Getting image source signatures
Copying blob 9824c27679d3 done
Writing manifest to image destination
rootless worksI always write image names in full, with docker.io/library/. Podman doesn’t assume Docker Hub the way Docker does, and the full name also makes it obvious where an image comes from.
Letting Containers Use Resource Limits
Later I want to cap how much memory and CPU each container gets, so one broken app can’t take the whole server down. For a rootless container, the limits are applied through the user’s own systemd, and by default systemd doesn’t give a user control over all of them.
This drop-in hands them over:
sudo mkdir -p /etc/systemd/system/user@.service.d
sudo nano /etc/systemd/system/user@.service.d/delegate.conf[Service]
Delegate=cpu cpuset io memory pidssudo systemctl daemon-reloadIt applies the next time the user’s systemd starts, so log out and back in (or reboot), then check:
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/cgroup.controllerscpuset cpu io memory pidsIf memory isn’t in that list, a container’s memory limit is silently ignored.
The Setting That Decides Everything
This surprised me the most. A user’s systemd services start when that user logs in and stop when they log out. So my containers would die the moment I closed SSH, and after a reboot at 4 AM nothing would come back.
Lingering fixes that:
sudo loginctl enable-linger admin
loginctl show-user admin --property=LingerLinger=yesNow admin gets its own systemd instance at boot, whether anyone is logged in or not. Without this line, Part 2’s automatic reboots would take every app offline.
One habit to go with it: run systemctl --user from a real login as that user. sudo -u admin systemctl --user ... fails with a confusing D-Bus error, because that shell isn’t connected to the user’s session. If you’re root and need a proper session as the user, machinectl shell admin@ gives you one.
Container Settings
Podman reads per-user settings from ~/.config/containers/containers.conf:
mkdir -p ~/.config/containers
nano ~/.config/containers/containers.conf[containers]
log_driver = "journald"
[engine]
events_logger = "journald"
[network]
default_rootless_network_cmd = "pasta"journald sends container output to the journal, so the log size cap from Part 2 covers containers too. No more log files growing forever somewhere in a container’s storage.
Allowing Ports 80 and 443
Ports below 1024 normally need root, and nothing here runs as root. In Part 4 the web ports will be opened by my user’s systemd, so the threshold has to come down:
sudo nano /etc/sysctl.d/90-rootless.confnet.ipv4.ip_unprivileged_port_start = 80
fs.inotify.max_user_watches = 524288
fs.inotify.max_user_instances = 1024sudo sysctl --systemOn a shared server, letting normal users take port 80 would be a bad idea. Here the only normal user is me, so that risk doesn’t really exist. The inotify lines raise how many files a user can watch for changes, because the reverse proxy in Part 4 watches its config files.
Where Apps Will Live
One more small decision. Every app gets a directory under one place in my home directory:
mkdir -p ~/appsNot /var/www, not /opt. Everything that matters on this server ends up in admin’s home: the apps, their settings, and (rootless) their container data. Backing up the server means backing up one directory.
Quadlet: Containers as systemd Services
My first idea was a compose.yml, because that’s what I knew. But then what starts the containers after a reboot? A restart policy needs a daemon, and Podman doesn’t have one.
Quadlet answers that. You write a small file that looks like a systemd unit, put it in ~/.config/containers/systemd/, and systemd turns it into a real service. Starting at boot, restarting on failure, ordering and logs all come from systemd.
mkdir -p ~/.config/containers/systemdThe First Container
Something throwaway, to learn the loop. whoami is a tiny web server that just prints details about the request:
nano ~/.config/containers/systemd/hello.container[Unit]
Description=Quadlet test container
[Container]
ContainerName=hello
Image=docker.io/traefik/whoami
PublishPort=127.0.0.1:8080:80
[Service]
Restart=always
[Install]
WantedBy=default.target[Unit]and[Install]are normal systemd sections.WantedBy=default.targetis what makes it start at boot.[Container]is Quadlet’s own section. Each line becomes an option forpodman run.[Service]is normal systemd too.Restart=alwaysgoes here, not under[Container]. I put it in the wrong one the first time.
127.0.0.1:8080 keeps the port on the server itself. The firewall would block 8080 from outside anyway, but I’d rather not publish it at all.
Before loading it, Quadlet can check the file:
/usr/libexec/podman/quadlet -dryrun -userIt prints the service it would generate, or an error saying which line it didn’t like. Then:
systemctl --user daemon-reload
systemctl --user start hello
systemctl --user status hello● hello.service - Quadlet test container
Loaded: loaded (/home/admin/.config/containers/systemd/hello.container; generated)
Active: active (running) since Fri 2026-05-22 11:04:18 WIB; 5s agodaemon-reload is the step that runs the generator. Every time you add or change a .container file, reload before starting. Editing the file and wondering why nothing changed is the classic Quadlet mistake.
Notice it says generated. You can’t systemctl --user enable a Quadlet service; the [Install] section plus a reload is how it gets enabled.
curl http://127.0.0.1:8080Hostname: 7d1e4b9a03c2
IP: 127.0.0.1
...
GET / HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: curl/8.14.1
Accept: */*A response means rootless containers, networking and the systemd part all work. Logs are where every other service’s logs are:
journalctl --user -u hello -fNow the real test. Does it come back after a reboot, with nobody logged in?
sudo rebootLog back in:
systemctl --user is-active helloactiveIf it says active, lingering works and the setup holds without me. If it doesn’t, check loginctl show-user admin --property=Linger first.
Clean up:
systemctl --user stop hello
rm ~/.config/containers/systemd/hello.container
systemctl --user daemon-reloadCleaning Up Old Images
Every deploy will pull a new image, and old ones pile up. A small timer in my user’s systemd removes images nothing has used for a week:
mkdir -p ~/.config/systemd/user
nano ~/.config/systemd/user/image-prune.service[Unit]
Description=Remove container images unused for a week
[Service]
Type=oneshot
ExecStart=/usr/bin/podman image prune --all --force --filter until=168hnano ~/.config/systemd/user/image-prune.timer[Unit]
Description=Weekly container image cleanup
[Timer]
OnCalendar=weekly
RandomizedDelaySec=1h
Persistent=true
[Install]
WantedBy=timers.targetsystemctl --user daemon-reload
systemctl --user enable --now image-prune.timerPersistent=true means if the server was off when the timer should have run, it runs as soon as it’s back.
Where the Server Stands
Rootless Podman works, it can enforce memory and CPU limits, containers survive logging out and rebooting, and my user can open ports 80 and 443.
There’s still no website. Part 4 puts Traefik in front for HTTPS.