Docker

Run Docker and Podman Containers as systemd Services

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.

Original content from computingforgeeks.com - post 52351

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.

ScenarioMethodComplexityAuto-Update
Single Docker containerManual systemd unit fileLowNo (manual pull)
Docker Compose multi-container stacksystemd unit calling docker composeLowNo (manual pull)
Single Podman container (recommended)Quadlet .container fileLowYes (podman auto-update)
Rootless Podman (no root needed)Quadlet + loginctl enable-lingerMediumYes
Podman (legacy systems)podman generate systemdLowNo

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.

FeatureDockerPodman (Quadlet)
Unit file creationManualAuto-generated from .container file
Rootless supportPossible but complexNative, first-class
Auto-update from registryNot built-in (needs Watchtower or similar)Built-in podman auto-update
Health checksDefined in Dockerfile or ComposeDefined in .container file
Daemon dependencyRequires docker.service runningDaemonless (conmon manages container)
Multi-container stacksCompose + systemd wrapper unitQuadlet .kube or multiple .container files
Boot startupsystemctl enableWantedBy= in the .container file
Crash recoverysystemd Restart=alwayssystemd Restart=always (identical)
Compose supportFull (docker compose)podman compose (via podman-compose or docker-compose)
Notification typeType=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.

Related Articles

AWS Running Docker Containers on AWS ECS – Upload Docker Images to ECR Web Hosting Install HestiaCP on Ubuntu 24.04/22.04 and Debian 13/12 Arch Linux Download online web pages as PDF with Percollate AI Install Ollama on Rocky Linux 10 / Ubuntu 24.04

Leave a Comment

Press ESC to close