Containers are stateless by design. Kill one, and it stays dead until something brings it back. That “something” on a Linux server is systemd, and getting the integration right means your containerized services survive reboots, recover from crashes, and start in the correct order alongside everything else on the box.
This guide covers every practical scenario: running a single Docker container as a systemd service, managing a full Docker Compose stack as one unit, using Podman’s modern Quadlet system (which replaced the now-deprecated podman generate systemd), and rootless Podman services that persist after logout. If you’ve already installed Podman on Ubuntu or have Docker running, you can jump straight to whichever section matches your setup.
Tested March 2026 on Ubuntu 24.04.4 LTS with Docker 28.2.2, Podman 4.9.3, Docker Compose 2.37.1
Quick Reference: Which Approach to Use
Before getting into the details, here’s a quick decision matrix. Pick the row that matches your situation.
| Scenario | Method | Complexity | Auto-Update |
|---|---|---|---|
| Single Docker container | Manual systemd unit file | Low | No (manual pull) |
| Docker Compose multi-container stack | systemd unit calling docker compose | Low | No (manual pull) |
| Single Podman container (recommended) | Quadlet .container file | Low | Yes (podman auto-update) |
| Rootless Podman (no root needed) | Quadlet + loginctl enable-linger | Medium | Yes |
| Podman (legacy systems) | podman generate systemd | Low | No |
Prerequisites
- Ubuntu 24.04 LTS (or Debian 13). Commands also work on Rocky Linux 10 / AlmaLinux 10 with minor package name differences
- Docker 28.x or Podman 4.9+ installed
- Root or sudo access (rootless Podman section covers unprivileged setup)
- Basic familiarity with running containers with Podman or Docker
Docker Container as a systemd Service
The simplest case: one container, one systemd unit. The trick is handling the lifecycle cleanly. Docker doesn’t remove stopped containers automatically, so the unit needs to clean up any leftover container with that name before starting a fresh one.
Create the Unit File
Open a new service file:
sudo vi /etc/systemd/system/docker-web.service
Add the following unit definition:
[Unit]
Description=Nginx web container (Docker)
After=docker.service
Requires=docker.service
[Service]
Type=simple
Restart=always
RestartSec=5
ExecStartPre=-/usr/bin/docker rm -f web-docker
ExecStart=/usr/bin/docker run --rm --name web-docker -p 8080:80 nginx:alpine
ExecStop=/usr/bin/docker stop web-docker
[Install]
WantedBy=multi-user.target
A few things worth noting here. ExecStartPre=- (with the minus sign) means “run this but don’t fail the unit if it errors out.” That handles the case where no old container exists. The --rm flag on docker run ensures the container is removed when it stops, keeping things clean. RestartSec=5 gives Docker a moment to release the port before systemd tries again.
Reload systemd and start the service:
sudo systemctl daemon-reload
sudo systemctl enable --now docker-web.service
Verify the service is running:
sudo systemctl status docker-web.service
The output should confirm systemd is managing the container:
● docker-web.service - Nginx web container (Docker)
Loaded: loaded (/etc/systemd/system/docker-web.service; enabled; preset: enabled)
Active: active (running) since Tue 2026-03-24 14:32:18 UTC; 5s ago
Process: 12841 ExecStartPre=/usr/bin/docker rm -f web-docker (code=exited, status=1/FAILURE)
Main PID: 12849 (docker)
Tasks: 7 (limit: 4573)
Memory: 28.4M
CPU: 312ms
CGroup: /system.slice/docker-web.service
└─12849 /usr/bin/docker run --rm --name web-docker -p 8080:80 nginx:alpine
The ExecStartPre showing status=1/FAILURE is expected on first run because there was no container to remove. That’s exactly what the - prefix handles.
Test Auto-Restart
The whole point of systemd integration is crash recovery. Force-kill the container and watch systemd bring it back:
docker kill web-docker
Wait about 5 seconds (the RestartSec value), then check again:
sudo systemctl status docker-web.service
Systemd restarts the container automatically:
● docker-web.service - Nginx web container (Docker)
Loaded: loaded (/etc/systemd/system/docker-web.service; enabled; preset: enabled)
Active: active (running) since Tue 2026-03-24 14:32:28 UTC; 3s ago
Process: 12903 ExecStartPre=/usr/bin/docker rm -f web-docker (code=exited, status=0/SUCCESS)
Main PID: 12910 (docker)
Tasks: 7 (limit: 4573)
Memory: 26.1M
CPU: 298ms
CGroup: /system.slice/docker-web.service
└─12910 /usr/bin/docker run --rm --name web-docker -p 8080:80 nginx:alpine
This time ExecStartPre exits with status=0/SUCCESS because it found and removed the dead container before starting a fresh one. The container recovers within 5 seconds of being killed.
Docker Compose Stack as a systemd Service
Most real deployments use Compose, not single containers. You can wrap an entire docker compose stack in a systemd unit so all containers start and stop together. On Ubuntu 24.04, the Docker Compose plugin is available as the docker-compose-v2 package.
Set Up the Compose Project
Create a directory for the stack and add the Compose file:
sudo mkdir -p /opt/mystack
Open the Compose file:
sudo vi /opt/mystack/compose.yml
Define a simple Nginx + Redis stack for demonstration:
services:
web:
image: nginx:alpine
ports:
- "8081:80"
depends_on:
- cache
cache:
image: redis:7-alpine
Create the systemd Unit
The key difference from a single container unit is the service type. Compose brings containers up and returns immediately, so the unit needs Type=oneshot with RemainAfterExit=yes to keep systemd aware that the service is still “active” after the docker compose up command exits.
sudo vi /etc/systemd/system/mystack.service
Add this configuration:
[Unit]
Description=My Docker Compose Stack (nginx + redis)
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/mystack
ExecStart=/usr/bin/docker compose up -d
ExecStop=/usr/bin/docker compose down
ExecReload=/usr/bin/docker compose up -d --force-recreate
[Install]
WantedBy=multi-user.target
The ExecReload directive is a bonus: systemctl reload mystack recreates the containers with any updated images or config changes, which is useful during deployments.
Enable and start the stack:
sudo systemctl daemon-reload
sudo systemctl enable --now mystack.service
Confirm both containers are running:
docker compose -f /opt/mystack/compose.yml ps
You should see both services up:
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
mystack-cache-1 redis:7-alpine "docker-entrypoint.s…" cache 8 seconds ago Up 7 seconds 6379/tcp
mystack-web-1 nginx:alpine "/docker-entrypoint.…" web 8 seconds ago Up 7 seconds 0.0.0.0:8081->80/tcp
The systemd unit manages both containers as a single logical service. systemctl stop mystack tears down the whole stack, and systemctl start mystack brings it all back.
Podman Quadlet: The Modern Approach
If you’re running Podman 4.4 or later, Quadlet is the way to manage containers with systemd. You write a declarative .container file (similar to a Compose service definition), drop it in the right directory, and Podman’s systemd generator creates the unit file automatically. No manual unit writing, no fragile shell commands in ExecStart.
Quadlet replaced podman generate systemd, which is now deprecated. The generated units from the old tool broke whenever Podman’s internal flags changed between versions. Quadlet avoids this entirely because the generator always produces units compatible with the installed Podman version.
Create a Quadlet Container File
For root-managed (system) services, Quadlet files live in /etc/containers/systemd/. Create the directory if it doesn’t exist:
sudo mkdir -p /etc/containers/systemd
Open a new Quadlet file:
sudo vi /etc/containers/systemd/webapp.container
Define the container:
[Unit]
Description=Nginx webapp via Podman Quadlet
After=network-online.target
[Container]
Image=docker.io/library/nginx:alpine
ContainerName=webapp
PublishPort=8082:80
AutoUpdate=registry
Label=io.containers.autoupdate=registry
[Service]
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
The [Container] section is the Quadlet-specific part. Everything else is standard systemd syntax. AutoUpdate=registry tells podman auto-update to check for newer images (covered in the auto-update section below).
Activate and Start the Service
Reload systemd so the Podman generator processes the new file:
sudo systemctl daemon-reload
The generator creates the actual unit at /run/systemd/generator/webapp.service. Start it:
sudo systemctl start webapp.service
Check the status:
sudo systemctl status webapp.service
The service should be active with the container running:
● webapp.service - Nginx webapp via Podman Quadlet
Loaded: loaded (/run/systemd/generator/webapp.service; generated)
Active: active (running) since Tue 2026-03-24 15:01:44 UTC; 4s ago
Main PID: 14221 (conmon)
Tasks: 3 (limit: 4573)
Memory: 18.2M
CPU: 186ms
CGroup: /system.slice/webapp.service
├─14219 /usr/bin/slirp4netns --disable-host-loopback ...
├─14221 /usr/bin/conmon --api-version 1 -c ...
└─14230 nginx: master process nginx -g daemon off;
Notice the Loaded line says “generated” instead of a path under /etc/systemd/system/. Quadlet units are generated on the fly, which is why you can’t use systemctl enable on them. The WantedBy=multi-user.target in the .container file handles boot startup instead.
Test Crash Recovery
Kill the container directly and see how fast systemd recovers it:
podman kill webapp
Wait 3 seconds (the RestartSec value), then verify:
sudo systemctl status webapp.service
Systemd restarts the container automatically. The new PID confirms a fresh container was launched:
● webapp.service - Nginx webapp via Podman Quadlet
Loaded: loaded (/run/systemd/generator/webapp.service; generated)
Active: active (running) since Tue 2026-03-24 15:01:51 UTC; 2s ago
Main PID: 14298 (conmon)
Tasks: 3 (limit: 4573)
Memory: 16.8M
CPU: 174ms
CGroup: /system.slice/webapp.service
├─14296 /usr/bin/slirp4netns --disable-host-loopback ...
├─14298 /usr/bin/conmon --api-version 1 -c ...
└─14307 nginx: master process nginx -g daemon off;
Recovery took 3 seconds. Compare that to a bare podman run with --restart=always, which only works while Podman is running and doesn’t survive reboots.
Rootless Podman with Quadlet
One of Podman’s strongest features is rootless containers. No root daemon, no privilege escalation risk. Quadlet supports this fully, but the file paths and systemd commands are different because you’re working with user-level systemd, not system-level.
Enable Lingering
By default, user-level systemd services stop when the user logs out. loginctl enable-linger keeps them running permanently, which is what you need for a server:
loginctl enable-linger $USER
Verify linger is enabled:
loginctl show-user $USER --property=Linger
The output confirms it:
Linger=yes
Create the Rootless Quadlet File
Rootless Quadlet files go in ~/.config/containers/systemd/ instead of the system-wide path:
mkdir -p ~/.config/containers/systemd
Create the container definition:
vi ~/.config/containers/systemd/myapp.container
Add the following (note: rootless containers can only bind to ports above 1024 unless you adjust net.ipv4.ip_unprivileged_port_start):
[Unit]
Description=My rootless app via Podman Quadlet
After=network-online.target
[Container]
Image=docker.io/library/nginx:alpine
ContainerName=myapp
PublishPort=8083:80
AutoUpdate=registry
Label=io.containers.autoupdate=registry
[Service]
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
Two differences from the root version: the path is under your home directory, and WantedBy uses default.target instead of multi-user.target because user-level systemd has different targets.
Reload and start using --user flags:
systemctl --user daemon-reload
systemctl --user start myapp.service
Check the status:
systemctl --user status myapp.service
You should see the service active under user-level systemd:
● myapp.service - My rootless app via Podman Quadlet
Loaded: loaded (/run/user/1000/systemd/generator/myapp.service; generated)
Active: active (running) since Tue 2026-03-24 15:10:32 UTC; 3s ago
Main PID: 15102 (conmon)
Tasks: 3 (limit: 4573)
Memory: 14.6M
CPU: 162ms
CGroup: /user.slice/user-1000.slice/[email protected]/app.slice/myapp.service
├─15100 /usr/bin/slirp4netns --disable-host-loopback ...
├─15102 /usr/bin/conmon --api-version 1 -c ...
└─15111 nginx: master process nginx -g daemon off;
The generated unit now lives under /run/user/1000/systemd/generator/ instead of /run/systemd/generator/. This container runs entirely without root privileges and survives reboots thanks to linger.
Podman Generate Systemd (Legacy)
Before Quadlet existed, podman generate systemd was the standard way to create systemd units for Podman containers. It still works in Podman 4.9.3 but prints a deprecation warning every time you use it. If you’re on an older system that doesn’t support Quadlet (Podman < 4.4), this is your only option. Everyone else should use Quadlet.
Start a container the usual way:
podman run -d --name web-podman -p 8084:80 nginx:alpine
Generate a systemd unit with the --new flag (creates a fresh container each time, rather than just starting/stopping the same one):
podman generate systemd --new --name web-podman --files
Podman creates the unit file but shows the deprecation warning:
WARN[0000] The generate systemd command is deprecated and will be removed in a future release. Use Quadlet for running containers and pods under systemd.
/home/user/container-web-podman.service
Move the generated file into the systemd directory and activate it:
sudo mv container-web-podman.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now container-web-podman.service
This works fine, but the generated unit contains hardcoded Podman command-line flags. When Podman updates and changes its internal flags (which happens regularly), the unit may break. Quadlet avoids this problem entirely, which is why the Podman team deprecated this approach.
Health Checks and Auto-Update
Production containers need health monitoring. Quadlet supports Podman’s built-in health check mechanism directly in the .container file, so you don’t need external monitoring just to know if a container is responsive.
Add Health Checks to a Quadlet
Open your Quadlet file (or create a new one):
sudo vi /etc/containers/systemd/webapp-health.container
Add HealthCmd and related options in the [Container] section:
[Unit]
Description=Nginx with health checks (Quadlet)
After=network-online.target
[Container]
Image=docker.io/library/nginx:alpine
ContainerName=webapp-health
PublishPort=8085:80
AutoUpdate=registry
Label=io.containers.autoupdate=registry
HealthCmd=curl -f http://localhost/ || exit 1
HealthInterval=30s
HealthRetries=3
HealthStartPeriod=10s
[Service]
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
HealthCmd runs inside the container every 30 seconds. If it fails 3 times in a row, the container is marked unhealthy. HealthStartPeriod gives the application 10 seconds to initialize before health checks begin counting failures.
Activate it:
sudo systemctl daemon-reload
sudo systemctl start webapp-health.service
Run a manual health check to confirm it’s working:
podman healthcheck run webapp-health
A healthy container returns:
healthy
You can also inspect the full health status with timestamps:
podman inspect --format='{{json .State.Health}}' webapp-health | python3 -m json.tool
Automatic Image Updates with podman auto-update
The AutoUpdate=registry directive and the io.containers.autoupdate=registry label you set earlier enable Podman’s automatic update mechanism. When you run podman auto-update, it checks if a newer version of each labeled container’s image exists in the registry and pulls/restarts if so.
Test it manually first:
sudo podman auto-update --dry-run
The dry run shows which containers would be updated without actually doing it. To run real updates on a schedule, enable the built-in systemd timer:
sudo systemctl enable --now podman-auto-update.timer
Check when the next update check is scheduled:
systemctl list-timers podman-auto-update.timer
By default, the timer runs daily. You can override the schedule by editing the timer unit if your deployment needs more or less frequent checks.
Docker vs Podman: systemd Integration Comparison
Both container runtimes work with systemd, but the integration depth is very different. This table covers the practical differences that affect your choice.
| Feature | Docker | Podman (Quadlet) |
|---|---|---|
| Unit file creation | Manual | Auto-generated from .container file |
| Rootless support | Possible but complex | Native, first-class |
| Auto-update from registry | Not built-in (needs Watchtower or similar) | Built-in podman auto-update |
| Health checks | Defined in Dockerfile or Compose | Defined in .container file |
| Daemon dependency | Requires docker.service running | Daemonless (conmon manages container) |
| Multi-container stacks | Compose + systemd wrapper unit | Quadlet .kube or multiple .container files |
| Boot startup | systemctl enable | WantedBy= in the .container file |
| Crash recovery | systemd Restart=always | systemd Restart=always (identical) |
| Compose support | Full (docker compose) | podman compose (via podman-compose or docker-compose) |
| Notification type | Type=simple (PID 1 is docker CLI) | Type=notify (conmon notifies systemd directly) |
Podman’s Type=notify is a practical advantage. The container runtime tells systemd exactly when the container is ready, which means dependent services (like a web app waiting for its database) start at the right time. Docker uses Type=simple, so systemd only knows the docker run CLI started, not whether the container inside is actually ready.
Production Tips
Viewing Container Logs via journalctl
Once a container runs as a systemd service, its stdout/stderr goes to the journal. No more chasing log files across /var/lib/docker/containers/:
journalctl -u webapp.service -f
For rootless Podman services, add the --user flag:
journalctl --user -u myapp.service -f
Filter by time range to investigate incidents:
journalctl -u webapp.service --since "2026-03-24 15:00:00" --until "2026-03-24 16:00:00"
Ordering Dependencies Between Containers
If your application container depends on a database container, use systemd’s dependency directives. In the application’s Quadlet file:
[Unit]
Description=Web application
After=network-online.target database.service
Requires=database.service
After= controls startup order. Requires= means the web app fails if the database service isn’t running. For a softer dependency where you want the app to start anyway, use Wants= instead of Requires=.
Debugging a Failed Container Service
When a containerized service won’t start, check these in order:
sudo systemctl status webapp.service
If the status shows “failed”, the journal has the details:
journalctl -u webapp.service -n 50 --no-pager
For Quadlet specifically, verify the generator processed your .container file correctly:
ls -la /run/systemd/generator/webapp.service
If the generated unit doesn’t exist, the .container file has a syntax error. Run the generator manually to see the error message:
/usr/lib/systemd/system-generators/podman-system-generator --dryrun
Common problems: a typo in the Image= line, a missing [Container] section header, or the file placed in the wrong directory.
Resource Limits
Since these are systemd services, you can use systemd’s native resource controls in the [Service] section of your Quadlet file or unit file:
[Service]
Restart=always
RestartSec=3
MemoryMax=512M
CPUQuota=50%
This limits the container to 512 MB of RAM and 50% of one CPU core. These limits are enforced by cgroups through systemd, which is more reliable than passing --memory and --cpus flags to the container runtime.
Summary
For Docker, a manual systemd unit with ExecStartPre cleanup handles single containers well, and a Type=oneshot wrapper manages Compose stacks. For Podman, Quadlet is the clear winner: declarative .container files, auto-generated units, built-in health checks, automatic image updates, and proper rootless support. The old podman generate systemd still works but is deprecated and should only be used on systems running Podman older than 4.4.
Whichever approach you pick, the core benefit is the same: systemd gives your containers boot persistence, crash recovery, dependency ordering, and centralized logging through journalctl. If you’re running containers in production, they should be managed by systemd. Also see our guide on running Jenkins in a Docker container with systemd for a real-world example of these patterns applied to a CI/CD server.