You Don’t Actually Need the Docker Daemon
Let me paint you a picture. It’s 2 AM. Your home server is running Docker, which is running a daemon, which is running as root, which is silently doing whatever it wants to your filesystem. Your Nginx container restarted itself three times for reasons unknown, your Watchtower container is updating things you didn’t ask it to update, and somewhere in that mess is a compose file you last touched eight months ago and no longer fully trust.
You know what would’ve helped? Not having a single all-knowing daemon sitting between you and your containers like an overconfident middle manager.
That’s the pitch for Podman. And once you add Quadlets into the mix, containers that systemd manages natively, like any other service, you get something that actually feels like it was designed for the way sane people run servers.
Let’s dig in.
What Even Is Podman?
Podman (short for Pod Manager) is a container engine that does what Docker does, pulls images, runs containers, manages volumes and networks, but without the daemon.
No background service. No root requirement. No single point of failure that takes down every container on your machine if it crashes.
Most people don’t realize that Podman is almost entirely CLI-compatible with Docker. Like, embarrassingly compatible. This works:
alias docker=podmanThat’s it. Most of your existing Docker commands just work. Same flags, same image format (OCI-compatible), same registries. That’s by design.
Installing Podman on Ubuntu/Debian
sudo apt updatesudo apt install -y podmanpodman --versionOn Ubuntu 24.04 LTS (and 22.04), you’ll get a recent Podman build straight from the default repos that already includes Quadlet support, no third-party repo needed anymore. A heads-up if you find older tutorials: the OpenSUSE Kubic repo that everyone used to recommend is now abandoned (it hasn’t shipped an updated Podman in years, and it relied on the deprecated apt-key). Don’t add it. Just use the official Ubuntu packages.
If you need a bleeding-edge Podman that’s newer than your distro ships, the cleaner route these days is a Fedora-based container or VM, or just run a current distro where Podman is fresh out of the box.
On Fedora or RHEL-based distros, Podman is already installed by default and you’re already living in the future.
The Rootless Part Actually Matters
Running containers as root is like giving every delivery driver a master key to your house because it’s easier than handing out specific room keys. Technically functional. Deeply cursed.
Podman’s rootless mode means containers run under your own user account. If something goes wrong inside a container (escape attempt, misconfiguration, whatever) it’s limited to what your user can do. No root access means no “oops, container wrote to /etc” incidents.
# This runs as YOU, not rootpodman run --rm hello-world
# Compare: Docker (traditionally requires sudo or docker group, which is basically root anyway)docker run --rm hello-worldBeing in the docker group is functionally equivalent to sudo access, by the way. That’s not a hot take, it’s documented by Docker themselves. Podman sidesteps this entirely.
Enter Quadlets: Systemd Actually Managing Your Containers
Okay, so you’ve switched to Podman. Now what? How do you make containers start on boot, restart on failure, and generally behave like proper services?
The old answer was podman generate systemd, a command that would spit out a systemd unit file based on an existing container. It worked, but it was janky. The generated files were verbose, hard to maintain, and felt like they were auto-generated (because they were). Every time you changed your container config, you had to regenerate the unit file.
The new answer is Quadlets.
Quadlets, introduced in Podman 4.4, let you write simple .container files that systemd reads natively through a generator. Instead of a 60-line auto-generated unit file, you write a clean INI-style config that looks like this:
[Unit]Description=My Nginx Container
[Container]Image=docker.io/library/nginx:latestPublishPort=8080:80
[Service]Restart=always
[Install]WantedBy=default.targetDrop that in the right directory, run two commands, and systemd is managing your container. That’s it.
Where Do Quadlet Files Live?
For rootless (user) containers:
~/.config/containers/systemd/For system-wide (root) containers:
/etc/containers/systemd/Your First Quadlet: Nginx
Let’s do this properly. Create the file:
mkdir -p ~/.config/containers/systemd/nano ~/.config/containers/systemd/nginx.container[Unit]Description=Nginx Web ServerAfter=network-online.target
[Container]Image=docker.io/library/nginx:latestPublishPort=8080:80Volume=%h/nginx/html:/usr/share/nginx/html:ro,Z
[Service]Restart=on-failureTimeoutStartSec=30
[Install]WantedBy=default.targetA few notes:
%his a systemd specifier that expands to your home directory. Handy.- The
:Zon the volume is a SELinux label: needed on Fedora/RHEL, harmless on Ubuntu. After=network-online.targetmeans the container won’t try to start before networking is ready. You’d think this was the default. It is not.
Now tell systemd to pick it up and start it:
# Reload the systemd user daemon to pick up new unitssystemctl --user daemon-reload
# Start itsystemctl --user start nginx.service
# Check statussystemctl --user status nginx.service
# Watch logsjournalctl --user -u nginx.service -fThat’s a container running as a proper systemd service. No Docker Compose, no daemon, no root. Just systemctl.
Notice there’s no systemctl enable. Quadlet services are generated, and systemd refuses to enable generated units. The [Install] section covers it instead: Quadlet does the enabling when the generator runs, so WantedBy=default.target already means “start on boot.” One more thing for rootless setups: user services only start when you log in unless you turn on lingering with loginctl enable-linger $USER. Skip that and your containers wait for an SSH session after every reboot.
A Production Example: Vaultwarden with Health Checks
The nginx example is fine for learning, but real workloads need environment variables, persistent storage, and something watching whether the container is actually healthy, not just running. Here’s a complete Quadlet for Vaultwarden (the self-hosted Bitwarden-compatible password manager):
[Unit]Description=Vaultwarden Password ManagerAfter=network-online.targetWants=network-online.target
[Container]Image=docker.io/vaultwarden/server:latestPublishPort=8000:80Volume=vaultwarden-data.volume:/dataEnvironment=DOMAIN=https://vault.example.comEnvironment=SMTP_HOST=smtp.example.comEnvironment=SMTP_FROM=vault@example.comEnvironment=SMTP_SECURITY=starttlsEnvironment=SMTP_PORT=587EnvironmentFile=%h/.config/containers/vaultwarden.envHealthCmd=curl -f http://localhost:80/alive || exit 1HealthInterval=60s
# Resource limits, because containers without limits are just vibesPodmanArgs=--memory=512m --cpus=0.5
[Service]Restart=alwaysRestartSec=30TimeoutStopSec=120
[Install]WantedBy=default.targetYour vaultwarden.env file holds the one thing you really don’t want sitting in plaintext inside a unit file:
ADMIN_TOKEN=your-long-random-admin-tokenEnvironmentFile works exactly like it does on a regular systemd service; keep it out of your dotfiles repo and chmod it 600. HealthCmd and HealthInterval are the part most tutorials skip: without them, systemd only knows the container process is running, not that Vaultwarden is actually answering requests. If the app wedges itself without crashing (it happens), a health check catches it, a bare Restart=always doesn’t.
Deploy it:
mkdir -p ~/.config/containers/systemd/# copy the .container file and .env file into that directorysystemctl --user daemon-reloadsystemctl --user start vaultwarden.servicesystemctl --user status vaultwarden.serviceNetworks as Quadlets Too
If you’re running multiple containers that need to talk to each other, you’ll want a dedicated network. That’s also a Quadlet:
[Network]Driver=bridgeThen reference it in your container files:
[Container]Image=docker.io/myuser/myapp:latestNetwork=myapp.networkPodman will create the network as a systemd service (myapp-network.service) and your container unit will automatically depend on it. The dependency graph is managed for you.
Grouping Containers: Pods vs Targets
Compose gives you one knob for a whole stack: docker compose up, docker compose down. With Quadlets, each container is its own unit, so the obvious question is how you start and stop an app plus its database as one thing. You have two options.
Option 1: A .pod Quadlet
Podman 5.0 added .pod files. A pod is a group of containers sharing one network namespace, the same idea as a Kubernetes pod.
[Pod]PublishPort=8080:80
[Install]WantedBy=default.targetEach container joins with one line:
[Container]Image=docker.io/library/postgres:17Pod=myapp.podEnvironmentFile=%h/.config/myapp/db.env[Container]Image=docker.io/myuser/myapp:latestPod=myapp.podEnvironment=DATABASE_URL=postgres://app:secret@localhost:5432/appsystemctl --user daemon-reloadsystemctl --user start myapp-pod.serviceQuadlet wires the dependencies both ways. Each container gets BindsTo= the pod service, so stopping the pod stops them all. The pod service gets Wants= for each container, so starting the pod starts them all. That second half is controlled by StartWithPod=, which defaults to true. Set it to false on a container you want to stop with the pod but start by hand.
Two things change inside a pod. Containers talk over localhost (note the DATABASE_URL above), and ports get published on the pod, not on the containers. The catch: two containers in one pod can’t both listen on port 80, because they share a network stack.
Option 2: A Plain systemd Target
A target is a regular systemd unit that exists only to group other units. It doesn’t go in the Quadlet directory; it’s a normal user unit:
[Unit]Description=My app stack
[Install]WantedBy=default.targetThen each container points at it:
[Unit]PartOf=myapp.target
[Container]Image=docker.io/library/postgres:17ContainerName=dbNetwork=myapp.networkEnvironmentFile=%h/.config/myapp/db.env
[Install]WantedBy=myapp.target[Unit]PartOf=myapp.targetRequires=db.serviceAfter=db.service
[Container]Image=docker.io/myuser/myapp:latestNetwork=myapp.networkPublishPort=8080:80Environment=DATABASE_URL=postgres://app:secret@db:5432/app
[Install]WantedBy=myapp.targetsystemctl --user daemon-reloadsystemctl --user enable --now myapp.targetWantedBy=myapp.target makes starting the target start the containers. PartOf=myapp.target makes stopping or restarting the target do the same to them. Requires= plus After= starts the database before the app. Each container keeps its own network namespace and finds the others by name over the shared network. Set ContainerName=, because Quadlet names containers systemd-<unit> by default, and DATABASE_URL would need systemd-db otherwise.
Which One?
Use a pod when the containers are tightly coupled and should live and die together: an app and its sidecar, a service and its metrics exporter. Use a target when they’re separate services that happen to ship together and each one needs its own ports and network identity. If you’re migrating a docker-compose.yml, a target maps more directly, because Compose containers each get their own network namespace too.
And if you’re still at the “spin it up and see if it’s worth keeping” stage, skip both. podman compose or podman kube play is fine for kicking the tires. Write Quadlets once the thing earns a permanent spot on the box.
Auto-Update: Keep Images Fresh Without Watchtower
Quadlets play nicely with Podman’s built-in auto-update mechanism, so you don’t need to bolt on a separate updater container. Add a label to any .container file:
[Container]Image=docker.io/myuser/myapp:latestLabel=io.containers.autoupdate=registryThat label tells Podman to check the registry for a newer digest and pull it when asked:
# See what would update, without touching anythingpodman auto-update --dry-run
# Actually updatepodman auto-updateTo run it daily, use the timer Podman already ships: podman-auto-update.timer, available for both system and user sessions. It fires daily with up to 15 minutes of random delay, and Persistent=true catches up on runs missed while the box was off. Turn it on:
systemctl --user enable --now podman-auto-update.timerUnlike your Quadlet services, this timer is a regular unit file, so enable works here. Don’t put hand-written .timer or .service files in ~/.config/containers/systemd/, by the way. Quadlet only reads its own file types from there and ignores everything else.
Podman pulls the new image, checks that the digest actually changed, and restarts only the containers carrying that label. No separate container watching your Docker socket, no extra attack surface. It’s auto-updates the Unix way: another small unit doing one job.
How This Compares to Compose (Both Flavors)
Docker Compose is still good for local dev and single-host deployments. It’s readable, it handles dependencies, and pretty much every self-hosted project ships a docker-compose.yml. Abandoning it entirely is not necessarily the right call. But once Podman’s in the picture, you’ve actually got three options, not two: plain Docker Compose, podman-compose running Podman with Compose YAML, or Quadlets going straight to systemd.
Here’s an honest comparison:
| Docker Compose | Podman Compose | Podman + Quadlets | |
|---|---|---|---|
| Startup on boot | docker-compose up in cron or rc.local | Same, via podman-compose up | Native systemd, WantedBy=default.target |
| Restart on failure | Compose restart: always (polling) | Compose restart: always (polling) | systemd supervision (proper) |
| Logs | docker compose logs | podman-compose logs | journalctl, integrated with the system log |
| Root required | Usually yes (or docker group, same thing) | No, rootless | No, rootless |
| Config format | YAML, portable across hosts | Same YAML, Podman semantics | INI-style unit files per container |
| Daemon required | Yes | No | No |
| Multi-container orchestration | Single compose file | Single compose file | Multiple unit files + network quadlets |
Stick with Docker Compose if you’re already in the Docker ecosystem, your team knows Compose, and migrating buys you nothing this quarter.
Use Podman Compose if you want Podman’s rootless daemonless model but you’re not ready to give up the portability of a single YAML file you can copy to any host.
Switch to Quadlets if you want systemd itself to be the orchestrator, you’re comfortable thinking in units instead of YAML, and you want systemctl status, journalctl, and systemd-analyze blame to just work on your containers like they do on everything else.
Honestly, the “I already know Docker” angle is mostly a non-issue. The CLI is identical for 90% of operations. The real mental shift is from “containers managed by a compose file” to “containers managed by systemd.” If you already think in terms of services and units, Quadlets are going to feel immediately natural.
A Note on the podman generate systemd Ghost
You’ll still find a lot of tutorials recommending podman generate systemd --new --name mycontainer. This was the pre-Quadlet approach and it worked, it generated a full systemd unit file from a running container. But it produced ugly, hard-to-maintain output, and the workflow was backwards: run the container first, then generate the service definition from it.
Quadlets flip this around. You define the intent, systemd handles the execution. It’s the right direction.
If you’re on Podman < 4.4 and can’t upgrade, generate systemd is still there. But if you’re setting up anything new, just use Quadlets.
The Practical Upshot
Here’s what the full loop looks like once you’ve got a Quadlet set up:
# Deploy a new version (update the image tag in your .container file, then:)systemctl --user restart myapp.service
# Check what's runningpodman ps
# Roll back (change image tag back, restart)systemctl --user restart myapp.service
# See why it crashedjournalctl --user -u myapp.service --since "10 minutes ago"
# Stop itsystemctl --user stop myapp.service
# Remove the service entirely: stop it, delete the .container file, reloadsystemctl --user stop myapp.servicerm ~/.config/containers/systemd/myapp.containersystemctl --user daemon-reloadThat’s a container lifecycle managed entirely through standard Linux tooling. No separate CLI to remember, no daemon to babysit, no compose file that’s technically a different tool entirely.
Go Forth and Run Rootless
Podman and Quadlets aren’t a revolution, they’re more like a quiet correction. Container management got complicated in ways it didn’t need to be, and this stack politely unfastens some of that complexity. You get containers that behave like services, run without root, and integrate with the init system that’s already managing everything else on your server.
Is it worth migrating your entire Docker setup tomorrow? Probably not. But for new services, for anything where security actually matters, or for anyone who’s ever thought “I wish this container just acted like a normal systemd service”, Podman + Quadlets is the answer.
Start with one container. Write the .container file. Run systemctl --user daemon-reload and systemctl --user start. Watch it just work.
Your 2 AM self will appreciate it.