Rootless Container Deployment Using Podman Quadlet and systemd

Updated on Sep 22, 2026
Kimberly N
8 MINS READ
Table of Contents
Run containers with Podman Quadlet and systemd

If you run containers in production, Docker Compose is easy to use, but it needs a background daemon running all the time. Podman Quadlet and systemd let you run the same multi-container stack without that daemon, while systemd handles restarts, startup at boot, and logs on its own. In this guide, you will learn building a small web stack using only Quadlet unit files and systemd.

What Is Podman Quadlet and Systemd

Quadlet is a tool built into Podman. You write small text files that describe a container, a network, a volume, or an image. Systemd reads these files and turns each one into a real .service unit automatically. 

Podman runs the containers, and systemd handles restarts, dependencies, boot order, and logging.

This is different from podman-compose, which copies Docker Compose's YAML style but still needs an extra tool running to work. It's also different from the older podman generate systemd command, which made fixed unit files from a container that was already running. That old method is deprecated now.

Quadlet files are the current, supported way, and systemd updates the service automatically whenever you edit a Quadlet file and reload.

Requirements Before You Start

You need a Linux server running Ubuntu 24.04 or newer with a recent kernel and systemd. Check these two things first, since Quadlet will not work without them.

  • Podman version 4.4 or newer. This guide uses the current stable Podman 6.x release.
  • cgroup v2 enabled, which is the default on modern distributions like Ubuntu 24.04.

Run this to confirm cgroup v2 is active:

Bash
podman info --format '{{.Host.CgroupsVersion}}'

It should print v2. If you are testing on a VPS, a Linux server with a recent kernel is the easiest place to try this out cleanly. If you don't already have one, a lightweight Linux VPS from PerLod is a good option because it gives you full root access to set up rootless Podman properly without extra restrictions.

Enable Rootless Containers

Running containers as a normal user, without root and without a daemon, is the safer default for production. This is one of the biggest reasons people move to Podman Quadlet and systemd instead of Docker.

First, you must make sure your user can start systemd services even when not logged in by enabling lingering:

Bash
sudo loginctl enable-linger $(whoami)

Without this, your rootless containers would stop the moment you log out of SSH.

Next, you must create the folder where your user-level Quadlet files live:

Bash
mkdir -p ~/.config/containers/systemd/

Podman's generator scans this exact folder for rootless units, along with $XDG_RUNTIME_DIR/containers/systemd/ for temporary units. Files placed here are read at boot and whenever you run systemctl --user daemon-reload.

Multi-Container Setup Using Podman Quadlet and Systemd

Here we will deploy a Postgres database, a small app container, and an Nginx reverse proxy in front of it. Each piece gets its own Quadlet file. This is where Podman Quadlet and systemd actually replace your docker-compose.yml.

Move into your Quadlet folder before creating files:

Bash
cd ~/.config/containers/systemd/

Create the Network Unit

Containers in the same stack need to talk to each other by name, so you must create a shared network first:

Bash
nano app.network

Paste this content into the file:

Bash
[Unit]Description=Shared network for the app stack [Network]NetworkName=app-net

Save and exit. This .network file will generate a systemd service called app-network.service, which creates a Podman network named app-net when started.

Create the Volume Unit

Every time a container restarts or gets rebuilt, it normally loses whatever was inside it. That's a problem for a database, since you can't afford to lose your data every time the container stops.

A volume solves this by keeping the data stored outside the container, on the host, so it stays safe no matter what happens to the container itself.

Bash
nano db.volume

Paste this content into the file:

Bash
[Unit]Description=Persistent volume for Postgres data [Volume]VolumeName=db-data

Create an Environment File

Don't put passwords directly inside the unit file. You must keep them in a separate environment file instead. Create the file with:

Bash
mkdir -p ~/.config/containers/envnano ~/.config/containers/env/db.env

Add your database credentials:

Bash
POSTGRES_USER=appuserPOSTGRES_PASSWORD=change-this-passwordPOSTGRES_DB=appdb

Now make sure only you can read this file:

Bash
chmod 600 ~/.config/containers/env/db.env

Create the Image Unit (Optional Pre-Pull)

An .image unit tells Podman to download an image early, as its own systemd service, instead of waiting until the container first starts. This helps if your network is slow or the image is large. It also lets other units wait for the download to finish before they start.

Bash
nano db.image
Bash
[Unit]Description=Pull the Postgres image [Image]Image=docker.io/library/postgres:16

Create the Database Container Unit

Now it's time to create the container that will actually run the database. This file tells Podman which image to use, which network and volume to connect, and how to check that the database is healthy.

Bash
nano db.container
Bash
[Unit]Description=Postgres database containerAfter=app-network.service db-volume.serviceRequires=app-network.service db-volume.service [Container]Image=db.imageContainerName=app-dbNetwork=app.networkVolume=db.volume:/var/lib/postgresql/dataEnvironmentFile=%h/.config/containers/env/db.envHealthCmd=pg_isready -U appuserHealthInterval=15sHealthRetries=3HealthTimeout=5sHealthStartPeriod=30sLogDriver=journald [Service]Restart=alwaysRestartSec=5 [Install]WantedBy=default.target

Create the App Container Unit

With the database ready, the next step is to create the app container itself. This one runs your actual application and connects to the database over the shared network.

Bash
nano app.container
Bash
[Unit]Description=Application containerAfter=app-network.service db.serviceRequires=app-network.serviceWants=db.service [Container]Image=docker.io/library/httpd:2.4ContainerName=app-webNetwork=app.networkNetworkAlias=appEnvironmentFile=%h/.config/containers/env/db.envHealthCmd=curl -f http://localhost/ || exit 1HealthInterval=20sHealthRetries=3LogDriver=journald [Service]Restart=on-failureRestartSec=5 [Install]WantedBy=default.target

Replace the httpd image with your real application image in a production setup. The NetworkAlias=app line lets the reverse proxy reach this container using the hostname app instead of an IP address.

Create the Reverse Proxy Container Unit

The last piece is the reverse proxy. It sits in front of your app and forwards incoming traffic to it, so the app itself doesn't need to be exposed directly.

Bash
nano proxy.container
Bash
[Unit]Description=Nginx reverse proxy containerAfter=app-network.service app.serviceRequires=app-network.serviceWants=app.service [Container]Image=docker.io/library/nginx:stableContainerName=app-proxyNetwork=app.networkPublishPort=8080:80Volume=%h/proxy/nginx.conf:/etc/nginx/nginx.conf:ro,ZHealthCmd=curl -f http://localhost/ || exit 1HealthInterval=15sHealthRetries=3LogDriver=journald [Service]Restart=alwaysRestartSec=5 [Install]WantedBy=default.target

Note: The :ro,Z suffix mounts the file as read-only and adds an SELinux label. You only need this if you're on Fedora or RHEL, since SELinux checks it.

Then, create a matching config file so Nginx has something to load:

Bash
mkdir -p ~/proxynano ~/proxy/nginx.conf
Bash
events {}http {  server {    listen 80;    location / {      proxy_pass http://app:80;    }  }}

Load and Start the Quadlet Units

Every time you add or edit a Quadlet file, you must tell systemd to re-read your unit directory:

Bash
systemctl --user daemon-reload

Now start everything in order. Because you added After= and Requires= in the [Unit] sections, systemd already knows the network and volume must come up before the containers, so you only need to start the top-level services:

Bash
systemctl --user start app-network.servicesystemctl --user start db-volume.servicesystemctl --user start db.servicesystemctl --user start app.servicesystemctl --user start proxy.service

Note: If you installed Podman using the static binary method instead of apt, you may hit a conmon failed: exit status 1 error with journald logging, since that build's bundled conmon lacks journald support. Fix it by installing the distro conmon package (sudo apt install conmon) so it takes priority on PATH, or switch LogDriver=journald to LogDriver=k8s-file in your .container files.

Check that everything is running:

Bash
systemctl --user status db.service app.service proxy.service

You should see active (running) for each, and after the health-start period passes, podman ps will show a healthy status next to each container.

Enable Automatic Startup for Containers at Boot

You already added [Install] WantedBy=default.target to each .container file. Because of that, systemd applies the boot-startup setting automatically as soon as the file loads; you don't need to run systemctl enable for these services.

Combined with loginctl enable-linger from earlier, this is how Podman Quadlet and systemd start your containers at boot, without needing you to log in or run a root daemon in the background.

To confirm the boot behavior is registered, dry-run the generator:

Bash
/usr/lib/systemd/system-generators/podman-system-generator --user --dryrun

This prints the exact .service file systemd will create from each Quadlet unit, which is the fastest way to catch a typo before it causes a real failure.

Note: If you installed Podman using the static binary method, the quadlet generator binary may not be symlinked into systemd's expected paths, causing Unit ... not found errors. Fix it with:

Bash
sudo ln -sf /usr/local/libexec/podman/quadlet /usr/lib/systemd/user-generators/podman-user-generatorsudo ln -sf /usr/local/libexec/podman/quadlet /usr/lib/systemd/system-generators/podman-system-generatorsystemctl --user daemon-reload

View Container Logs via journald

One advantage of using Podman Quadlet and systemd together is that you get unified logging for free. Since every container unit uses LogDriver=journald, you read logs exactly like any other systemd service.

Bash
journalctl --user -u app.service -fjournalctl --user -u db.service --since "10 minutes ago"

You don't need an extra log container just to see basic output. But if you'd rather see everything on a simple dashboard than read logs, check out our guide on self-hosting Beszel for VPS and Docker monitoring. It shows you an easy way to see container health across your server.

Update Images and Restart Services

To pull a new image and redeploy a service, update the Image= value in the relevant .container file or just re-pull the same tag, then run:

Bash
systemctl --user daemon-reloadsystemctl --user restart app.service

For automatic updates on a schedule, Podman also supports AutoUpdate=registry inside the [Container] section, paired with the built-in podman-auto-update.timer.

Conclusion

At this point, you have a real multi-container stack running with Podman Quadlet and systemd. Rootless, with health checks, dependencies, journald logging, and boot startup, all without Docker Compose or a background daemon. It's easier to manage because it integrates seamlessly with the systemctl and journalctl tools you already use. 

We hope you enjoy this guide. For more detailed information, you can check the Podman Quadlet official documentation.

No. Quadlet is a Podman-only feature and does not need Docker or Docker Compose at all.

In ~/.config/containers/systemd/ for your user, or /etc/containers/systemd/ for root-level units.

Yes, using a .build unit, which builds an image from a Containerfile as its own systemd service.