✦ For everyone, free.

Practical knowledge for real and everyday life

Home

14.3.1.3 Single Systemd Wrapper

A focused guide to Single Systemd Wrapper, connecting core concepts with practical Docker and container operations.

A single systemd wrapper is a unit file that delegates a service's start, stop, and restart lifecycle to systemd while the actual workload runs inside a Docker container, giving an operator the familiar systemctl interface and systemd's own dependency ordering, logging integration, and boot-time activation for a containerized application that Docker's own --restart flag does not fully provide on its own.

Why wrap a container in systemd at all

Docker's --restart=unless-stopped policy already restarts a crashed container automatically, but it operates entirely within Docker's own world: it does not participate in systemd's dependency ordering (waiting for the network or another system service to be ready first), does not appear in systemctl status output alongside other host services, and does not integrate with journalctl the way a native systemd service does. A systemd wrapper unit closes that gap for operators who manage the rest of the host through systemd and want a consistent interface across both containerized and non-containerized services.

[Unit]
Description=My API container
After=docker.service network-online.target
Requires=docker.service
Wants=network-online.target

[Service]
Restart=always
ExecStartPre=-/usr/bin/docker rm -f my-api
ExecStart=/usr/bin/docker run --rm --name my-api -p 3000:3000 my-api:1.4.0
ExecStop=/usr/bin/docker stop my-api

[Install]
WantedBy=multi-user.target
systemctl daemon-reload
systemctl enable --now my-api.service

The ExecStartPre line removing any stale container with the same name handles the case where a previous, uncleanly stopped container is still present, which would otherwise cause the subsequent docker run to fail with a name conflict.

Foreground execution is required

Because systemd needs to track the lifecycle of the process it starts, the container must run in the foreground (without -d) so that docker run itself remains the tracked process for the duration of the service's lifetime:

ExecStart=/usr/bin/docker run --rm --name my-api -p 3000:3000 my-api:1.4.0

Running with -d would cause docker run to exit immediately after starting the container in the background, which systemd would interpret as the service having stopped, defeating the purpose of the wrapper entirely.

Choosing between Type=simple and Type=notify

For most container wrapper units, Type=simple (the default) is sufficient, since systemd just needs to track the docker run process for as long as the container is meant to be running:

[Service]
Type=simple
ExecStart=/usr/bin/docker run --rm --name my-api my-api:1.4.0

A more sophisticated setup can have the application signal systemd directly once it is actually ready to serve traffic, using Type=notify, though this requires the containerized process itself to speak systemd's readiness protocol, which is uncommon for typical containerized applications and usually not worth the added complexity for a single wrapper unit.

Restart behavior and backoff

systemd's own restart policy can complement or, in most wrapper setups, fully replace Docker's --restart flag, since the two mechanisms would otherwise both attempt to restart the same failed container redundantly:

[Service]
Restart=always
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=5

StartLimitBurst and StartLimitIntervalSec prevent a container that is crash-looping from being restarted indefinitely in a tight loop; after the configured number of failures within the interval, systemd stops trying and marks the unit as failed, which is generally easier to notice and investigate than an indefinitely restarting container.

Logging through journald

Output from the container's main process is captured by systemd as part of the unit's own journal entries, giving access to journalctl for a containerized service the same way it would work for a native process:

journalctl -u my-api.service -f

This works because docker run in the foreground inherits the unit's standard output and error streams, which systemd captures regardless of whether the underlying process happens to be a container or an ordinary binary.

Updating the image used by the wrapper

Deploying a new version through a systemd wrapper means updating the unit file or an environment file it references, then restarting the unit, rather than manipulating the container directly with docker commands outside of systemd's awareness:

EnvironmentFile=/etc/my-api/version.env
ExecStart=/usr/bin/docker run --rm --name my-api my-api:${IMAGE_TAG}
echo "IMAGE_TAG=1.5.0" > /etc/my-api/version.env
systemctl restart my-api.service

Changing the container's configuration outside of systemd, for example by manually running docker stop and starting a different container under the same name, leaves systemd's own record of the unit's state inconsistent with what is actually running, which is the same kind of drift that affects any deployment path bypassing its intended management layer.

When a wrapper is the wrong amount of complexity

A systemd wrapper adds a layer of indirection that is worth it primarily when a host already relies on systemd for managing other services and benefits from a consistent interface, or when boot-order dependencies (such as waiting for a mounted volume or network interface) genuinely matter. For a host running many containers managed by Compose or a fuller orchestrator, wrapping each container individually in its own systemd unit is usually redundant with capabilities the orchestrator already provides more completely.

docker compose up -d

A single Compose-managed stack, itself optionally wrapped in one systemd unit that calls docker compose up and down, is often a better fit than wrapping every individual container in its own separate unit file.

Common mistakes

  • Running the container in detached mode (-d) inside the unit's ExecStart, causing systemd to lose track of the actual running process.
  • Leaving both Docker's --restart flag and systemd's Restart= directive active simultaneously, creating two independent and potentially conflicting restart mechanisms for the same container.
  • Forgetting the ExecStartPre cleanup step for a stale container with the same name, causing the unit to fail to start after an unclean previous shutdown.
  • Wrapping every container of a multi-service Compose stack in its own individual systemd unit instead of wrapping the stack as a whole, multiplying the maintenance burden without a corresponding benefit.
  • Modifying the running container directly with docker commands outside of systemd, leaving the unit's tracked state inconsistent with what is actually running.

A single systemd wrapper is a useful, lightweight integration point for a host that already standardizes on systemd for service management, giving a containerized application the same restart, logging, and dependency-ordering behavior as any other host service, provided the container runs in the foreground and Docker's own restart mechanism is left disabled to avoid conflicting with systemd's.