3 min read

Managing Docker Compose Applications with systemd - A Portainer Example

Stop manually restarting containers after reboots. Use systemd to auto-start Docker Compose apps and manage them with systemctl. Includes Portainer example.
Managing Docker Compose Applications with systemd - A Portainer Example
Photo by Taylor Vick / Unsplash

While setting up my own private container registry with Harbor (more on that in a future post), I ran into the usual problem: how do you manage Docker Compose applications properly on a production server? Sure, you can SSH in and run docker-compose up -d, but that's not how you run services in production.

The answer is systemd. If you're running Linux, you're probably already using it for everything else. Why not your Docker apps?

The Problem

Most Docker tutorials stop at "run docker-compose up and you're done!" That's fine for development, but in production, you need:

  • Automatic startup on boot
  • Proper dependency management (don't start the app before Docker is ready)
  • Integration with standard service management tools
  • Centralised logging through journald

Running containers manually with docker-compose is messy. You end up with screen sessions, or worse, containers that don't restart after a reboot.

A Simple Example: Portainer

Let's use Portainer as an example. It's a single-container Docker management UI, simple enough to understand but real enough to be useful.

Here's a basic docker-compose.yml:

services:
  portainer:
    image: portainer/portainer-ce:latest
    container_name: portainer
    restart: always
    ports:
      - "9000:9000"
      - "9443:9443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - portainer_data:/data

volumes:
  portainer_data:

Now, instead of manually running docker-compose up -d, create a systemd service at /etc/systemd/system/portainer.service:

[Unit]
Description=Portainer Docker Management UI
Documentation=https://docs.portainer.io
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/portainer
ExecStart=/usr/local/bin/docker-compose up -d
ExecStop=/usr/local/bin/docker-compose down
Restart=on-failure
RestartSec=30s
TimeoutStartSec=120
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Enable and start it:

systemctl daemon-reload
systemctl enable portainer.service
systemctl start portainer.service

That's it. Now you can manage it like any other service:

systemctl status portainer
systemctl restart portainer
journalctl -u portainer -f

Breaking Down the Service File

A few things worth explaining:

Type=oneshot with RemainAfterExit=yes: This is critical. Docker Compose starts containers and exits. Without RemainAfterExit=yes, systemd would think the service failed because the process ended. This combination tells systemd "the service is up as long as the start command succeeded."

After and Requires: These ensure Docker is fully running before we try to start containers. After controls ordering, Requires creates a dependency. If Docker stops, this service stops too.

Restart=on-failure: If docker-compose fails (returns non-zero), systemd will retry after 30 seconds. This handles transient failures.

StandardOutput/StandardError=journal: Sends all output to journald, so you can use journalctl instead of hunting for log files.

The Gotchas

This approach works well, but there are some things to watch out for:

  1. The restart: always in docker-compose.yml still matters. The systemd service manages docker-compose, not the individual containers. If a container crashes, Docker's own restart policy handles it. If docker-compose itself fails, systemd handles it.
  2. ExecStop runs docker-compose down. This removes the containers. If you just want to stop them, use docker-compose stop instead. I prefer down because it's cleaner, but it means restarts take slightly longer.
  3. WorkingDirectory must contain docker-compose.yml. Docker Compose looks in the current directory. If you don't set this, it'll fail.
  4. Path to docker-compose might differ. Use which docker-compose to find it. On some systems, it's /usr/bin/docker-compose, others /usr/local/bin/docker-compose.

Why Not Just Use Docker's Restart Policy?

You could rely entirely on restart: always in the compose file. Docker will restart containers, even after a reboot (once Docker itself starts). So why bother with systemd?

  • Visibility: systemctl status shows you the state at a glance
  • Control: Standard tooling across all services
  • Dependencies: You can chain services (mount storage before starting the app)
  • Logging: Centralised in journald with all your other services

For simple deployments, Docker's restart policy is probably enough. But once you have multiple services with dependencies, systemd becomes invaluable.

Real-World Complexity

The Harbor setup I'm working on is more complex. It needs an NFS mount to be ready before Harbor starts, and Harbor itself manages about 10 different containers. The systemd service handles the dependency chain: network → NFS mount → Docker → Harbor.

That's a longer story for another post, but the principle is the same. Systemd gives you proper service management instead of hoping everything comes up in the right order after a reboot.

Final Thoughts

This isn't groundbreaking stuff. Systemd has been around forever, and people have been wrapping Docker Compose in service files for years. But it's surprising how many tutorials skip this step entirely.

If you're running anything beyond a dev environment, take 10 minutes to write a proper service file. Your future self will thank you when the server reboots and everything just works.


Coming up next: I'm documenting my entire Harbor setup - a production-grade private container registry running on Photon OS, with NFS-backed storage served from an Unraid array, complete SSL setup via Nginx Proxy Manager, and vulnerability scanning with Trivy. It's been an interesting journey working through NFS mount dependencies, systemd service chaining, and troubleshooting some nasty 401 errors caused by disk spin-down behaviour. Watch this space.