Run Docker Compose as a Linux Service with systemd

Docker Compose on boot, managed by systemd.

Page content

Docker Compose on a Linux server should start on boot, stop cleanly on shutdown, and survive reboots without manual intervention.

Docker Compose is not Kubernetes, and that is fine for the workloads this guide targets. For many real systems, a Compose project on a single Linux host is the right amount of infrastructure — simple, readable, easy to back up, and good enough for internal tools, side projects, self-hosted services, staging environments, small production apps, and developer infrastructure.

docker compose config ont the table with laptop

The missing piece is usually service management. Running this manually is not enough:

docker compose up -d

A single command starts the stack, but it does not document how the stack should start on boot, stop during shutdown, reload after changes, write logs, recover from failures, or get updated safely. That is where systemd helps.

This guide walks through running a Docker Compose project as a Linux service with systemd — unit files, boot ordering, updates, logs, and backups. The split of responsibility is deliberate: Docker runs containers, Compose defines the stack, and systemd starts and stops the project on the host. It is part of Developer Tools - a Guide to Development Workflows.

When Docker Compose as a Service Makes Sense

Running Compose under systemd makes sense when you have:

  • A single Linux server
  • A small self-hosted application
  • A reverse proxy stack
  • A monitoring stack
  • A local development platform
  • An internal tool
  • A staging environment
  • A simple production service with known limits

Examples:

  • Nginx Proxy Manager
  • Traefik
  • Gitea
  • Grafana and Prometheus
  • PostgreSQL plus a small web app
  • Uptime Kuma
  • Home Assistant helper services
  • Private registry
  • Internal API plus worker plus Redis

Compose is a good fit when the operational model is still understandable by one person reading one directory.

When Docker Compose Is Not Enough

Use something else when you need:

  • Multi-node scheduling
  • Automatic rescheduling across hosts
  • Cluster-level service discovery
  • Horizontal autoscaling
  • Rolling deployments across many machines
  • Fine-grained workload identity
  • Complex network policy
  • Large multi-team platform operations

At that point, Kubernetes, Nomad, Swarm, or a managed platform may be a better fit.

My practical rule is to avoid using Kubernetes just to skip learning systemd, and to avoid using Compose when the workload clearly needs orchestration across multiple hosts.

The Basic Architecture

A clean setup separates project files, the systemd unit, and persistent data on the host. The Compose project lives under /opt/myapp/ with compose.yaml, .env, data/, backups/, and optional scripts such as scripts/update.sh. The systemd unit file sits at /etc/systemd/system/myapp.service.

flowchart TB subgraph host["Linux host"] systemd["systemd unit\n/etc/systemd/system/myapp.service"] compose["Docker Compose\n/opt/myapp/compose.yaml"] docker["Docker Engine"] fs["Persistent data\n/opt/myapp/data/"] end systemd -->|"ExecStart: docker compose up -d"| compose compose --> docker docker --> fs

Each layer has a clear job: Docker runs containers, Compose defines the application stack, systemd starts and stops the Compose project on boot and shutdown, the host filesystem stores persistent data, backups stay explicit, and updates go through scripted, reviewable steps. This layout is deliberately boring, because boring infrastructure is easier to repair when something breaks at 2 a.m.

Prepare the Compose Project Directory

Create a directory under /opt:

sudo mkdir -p /opt/myapp
sudo chown -R "$USER":"$USER" /opt/myapp
cd /opt/myapp

Create a Compose file:

nano compose.yaml

Example:

services:
  web:
    image: nginx:stable
    restart: unless-stopped
    ports:
      - "8080:80"
    volumes:
      - ./html:/usr/share/nginx/html:ro
    healthcheck:
      test: ["CMD-SHELL", "nginx -t || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 10s

volumes: {}

Create the content directory:

mkdir -p html
echo "Hello from Docker Compose" > html/index.html

Test manually first:

docker compose up -d
docker compose ps
docker compose logs --tail=50

Then stop it before handing lifecycle to systemd:

docker compose down

Do not create a systemd service until the Compose project works manually. While you test, keep the Docker Compose Cheatsheet nearby for ps, logs, pull, and project structure.

Use the Modern docker compose Command

Docker Engine and the Compose plugin must be installed before you write a unit file. On Ubuntu, Install Docker on Ubuntu walks through APT, Snap, rootless mode, and post-install security so you end up with a working docker compose command.

Use this:

docker compose version

Not this:

docker-compose version

The old docker-compose binary still exists on many machines, but modern Docker uses Compose as a Docker CLI plugin.

In service files and scripts, prefer:

/usr/bin/docker compose

You can find the Docker path with:

command -v docker

Usually it is:

/usr/bin/docker

Create a systemd Service for Docker Compose

If unit files are new to you, Run any Executable as a Service in Linux explains Type, ExecStart, systemctl, and the general systemd workflow. This section applies those patterns specifically to a Compose stack.

Create the service file:

sudo nano /etc/systemd/system/myapp.service

Use this unit:

[Unit]
Description=MyApp Docker Compose stack
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/myapp
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0
TimeoutStopSec=120

[Install]
WantedBy=multi-user.target

Reload systemd:

sudo systemctl daemon-reload

Start the service:

sudo systemctl start myapp.service

Enable it on boot:

sudo systemctl enable myapp.service

Check status:

systemctl status myapp.service

Check containers:

cd /opt/myapp
docker compose ps

Why Type=oneshot and RemainAfterExit=yes?

This is the part many guides get subtly wrong.

docker compose up -d starts containers in detached mode and exits, so there is no long-running foreground Compose process for systemd to supervise. The systemd unit should not pretend that docker compose up -d is a long-running daemon.

Use:

Type=oneshot
RemainAfterExit=yes

This tells systemd:

  • Run the start command.
  • Consider the unit active after the command exits successfully.
  • Run ExecStop when the service is stopped.

That matches the actual behavior of detached Compose, which is why Type=oneshot with RemainAfterExit=yes is the right default for most stacks.

Why Not Type=simple?

With Type=simple, systemd expects the ExecStart process to keep running, but docker compose up -d exits after starting containers. That can make systemd think the service ended, then call stop logic or mark the unit inactive depending on configuration.

If you want Type=simple, you would usually run Compose in the foreground:

ExecStart=/usr/bin/docker compose up

That can work, but I usually do not prefer it for Compose stacks on servers. Detached containers plus explicit ExecStop are easier to operate.

A More Production-Friendly Unit

For a real server, I prefer a slightly stricter unit:

[Unit]
Description=MyApp Docker Compose stack
Documentation=https://example.com/docs/myapp
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/myapp
EnvironmentFile=-/opt/myapp/.env.systemd
ExecStartPre=/usr/bin/docker compose config --quiet
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecReload=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0
TimeoutStopSec=120

[Install]
WantedBy=multi-user.target

Important details:

  • WorkingDirectory points to the Compose project.
  • ExecStartPre validates the Compose config.
  • ExecReload recreates changed services.
  • ExecStop stops and removes the Compose project containers and default network.
  • EnvironmentFile=-... means the file is optional.

Create the optional systemd environment file:

nano /opt/myapp/.env.systemd

Example:

COMPOSE_PROJECT_NAME=myapp

Then reload systemd:

sudo systemctl daemon-reload
sudo systemctl restart myapp.service

Compose .env vs systemd EnvironmentFile

Compose and systemd each have their own environment mechanism, and mixing them up causes confusing “variable not set” failures at boot.

Compose automatically reads a .env file in the project directory for variable substitution in the Compose file.

Example .env:

APP_TAG=1.2.3
WEB_PORT=8080

Example compose.yaml:

services:
  web:
    image: nginx:${APP_TAG}
    ports:
      - "${WEB_PORT}:80"

A systemd EnvironmentFile sets environment variables for the docker compose command itself.

Example:

EnvironmentFile=-/opt/myapp/.env.systemd

For many projects, you only need Compose .env.

Use a systemd environment file when you want to define things such as:

COMPOSE_PROJECT_NAME=myapp
COMPOSE_FILE=compose.yaml
DOCKER_HOST=unix:///var/run/docker.sock

Do not use either file as a casual secrets vault. If secrets matter, use Docker secrets, an external secret manager, encrypted files, or at least strict permissions.

Set restrictive permissions:

chmod 600 /opt/myapp/.env
chmod 600 /opt/myapp/.env.systemd

Restart Policies: Docker vs systemd

There are two restart layers — container restart policy in Compose and systemd service restart policy — and they should not be mixed blindly.

For long-running containers, set restart policies in Compose:

services:
  web:
    image: nginx:stable
    restart: unless-stopped

Common restart values:

Policy Meaning
no Do not restart automatically
always Restart after exit and daemon restart
on-failure Restart only after failure
unless-stopped Restart unless manually stopped

For most persistent services, I prefer:

restart: unless-stopped

It is predictable and respects intentional manual stops.

The systemd unit itself should usually not restart repeatedly, because docker compose up -d is not the running workload. The containers are.

So avoid this unless you have a specific reason:

Restart=always

In most Compose-as-service units, let Docker handle container restarts.

Health Checks

Restart policies restart containers when processes exit. They do not magically fix every unhealthy application.

Add health checks where they are useful:

services:
  app:
    image: example/app:latest
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s

Check health:

docker compose ps

Inspect a container:

docker inspect container-name

Health checks are especially useful for:

  • Web apps
  • Reverse proxies
  • Databases
  • Queues
  • Internal APIs
  • Workers with a health endpoint

They are less useful when they only check that a process exists, because a process that is alive but wedged still looks healthy. A bad health check is just another lie in YAML.

Startup Order and depends_on

Compose can define dependencies:

services:
  app:
    image: example/app:latest
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

This can help startup ordering, but do not over-trust it. Applications should still handle retries — databases restart, networks flap, DNS takes time, and a resilient app retries connections instead of assuming perfect startup order.

Logs: journalctl and docker compose logs

Two log views cover most debugging: systemd captures the lifecycle of the unit itself, while Compose captures application output from running containers.

systemd service logs:

journalctl -u myapp.service -n 100 --no-pager

Follow systemd logs:

journalctl -u myapp.service -f

Compose service logs:

cd /opt/myapp
docker compose logs --tail=100
docker compose logs -f
docker compose logs -f web

For most app debugging, docker compose logs is more useful; for lifecycle debugging — start failures, unit crashes, permission errors — journalctl is more useful. If systemctl start myapp fails, check journalctl first. If the stack starts but the app is broken, check docker compose logs.

Log Rotation

Docker logs can grow forever if you do not configure them.

For small servers, configure Docker log rotation in /etc/docker/daemon.json:

{
  "log-driver": "json-file",
  "log-opts": {
    "max-size": "10m",
    "max-file": "5"
  }
}

Restart Docker:

sudo systemctl restart docker

Then restart the Compose stack:

sudo systemctl restart myapp.service

This applies to newly created containers. Recreate containers if needed:

cd /opt/myapp
docker compose up -d --force-recreate

Log rotation is not glamorous, but it is one of the easiest ways to prevent a disk-full outage on a small server.

Updating a Compose Service

A simple manual update flow:

cd /opt/myapp
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f

If managed by systemd, you can use:

sudo systemctl reload myapp.service

If your unit has:

ExecReload=/usr/bin/docker compose up -d --remove-orphans

But note: ExecReload does not pull images unless you include that step.

For explicit updates, create a script.

mkdir -p /opt/myapp/scripts
nano /opt/myapp/scripts/update.sh

Script:

#!/usr/bin/env bash
set -euo pipefail

cd /opt/myapp

docker compose config --quiet
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
docker compose ps

Make it executable:

chmod +x /opt/myapp/scripts/update.sh

Run it:

/opt/myapp/scripts/update.sh

Then the service unit can remain focused on lifecycle, while the update script handles deployment.

Safer Update Script with Backup Hook

For stateful services, update only after backup.

#!/usr/bin/env bash
set -euo pipefail

APP_DIR="/opt/myapp"
BACKUP_DIR="/opt/myapp/backups"

cd "$APP_DIR"

mkdir -p "$BACKUP_DIR"

echo "Validating compose file"
docker compose config --quiet

echo "Running backup hook"
if [ -x "$APP_DIR/scripts/backup.sh" ]; then
  "$APP_DIR/scripts/backup.sh"
else
  echo "No backup hook found"
fi

echo "Pulling images"
docker compose pull

echo "Recreating services"
docker compose up -d --remove-orphans

echo "Pruning unused images"
docker image prune -f

echo "Current status"
docker compose ps

This is still simple, but now it encodes an operational habit: backup before change.

Stopping the Service

Stop the stack:

sudo systemctl stop myapp.service

That runs:

docker compose down

By default, docker compose down removes:

  • Containers for services in the Compose file
  • Networks defined by the Compose file
  • The default network

It does not remove named volumes unless you ask it to.

Do not casually use:

docker compose down -v

That removes named volumes declared in the Compose file and anonymous volumes attached to containers. For databases and stateful apps, that can mean deleting real data.

Use down -v only when you mean “destroy this environment”.

Restarting the Service

Restart the systemd unit:

sudo systemctl restart myapp.service

This runs the stop command and then the start command.

For only restarting containers without recreating them:

cd /opt/myapp
docker compose restart

Important distinction:

  • docker compose restart restarts existing containers.
  • docker compose up -d applies config or image changes by recreating containers when needed.

If you changed compose.yaml, use:

docker compose up -d

Not just:

docker compose restart

Handling Orphan Containers

If you rename or remove a service in compose.yaml, old containers may remain as orphans.

Use:

docker compose up -d --remove-orphans

That is why the systemd service examples in this guide use:

ExecStart=/usr/bin/docker compose up -d --remove-orphans

It keeps the stack closer to the current Compose file.

Backups

Backups depend on the workload, but the principles are stable.

For bind mounts:

/opt/myapp/data/

Back up that directory.

For named volumes:

docker volume ls

Inspect a volume:

docker volume inspect volume-name

For databases, filesystem copies are not always enough. Use application-aware backups:

PostgreSQL example:

docker compose exec -T db pg_dump -U postgres appdb > backups/appdb.sql

MariaDB example:

docker compose exec -T db mariadb-dump -u root -p appdb > backups/appdb.sql

Redis example:

docker compose exec redis redis-cli BGSAVE

A Compose stack without a backup plan is not a service — it is a temporary experiment that happens to have uptime.

Security Baseline

For a small Compose service on Linux, start with this baseline:

  • Keep the Compose project under /opt/appname.
  • Use explicit image tags, not only latest, when stability matters.
  • Use bind mounts or named volumes deliberately.
  • Do not expose ports you do not need.
  • Put public services behind a reverse proxy.
  • Use HTTPS at the edge.
  • Keep secrets out of Git.
  • Restrict .env permissions.
  • Avoid privileged containers unless truly required.
  • Avoid mounting the Docker socket into containers.
  • Keep Docker and images updated.
  • Test firewall behavior from another machine.

A dangerous pattern:

volumes:
  - /var/run/docker.sock:/var/run/docker.sock

This gives the container control over Docker. In practice, that can become host-level control. Use it only when you understand the risk.

Resource Limits

On small servers, one bad container can consume the host.

Compose supports resource-related settings, but behavior can depend on Docker Engine and Compose version. For simple protection, start with application-level limits and Docker logging limits.

For some workloads, you can add memory limits:

services:
  app:
    image: example/app:stable
    restart: unless-stopped
    mem_limit: 512m

Also configure app-level worker counts, queue limits, and cache sizes. Container limits are useful, but they are not a substitute for understanding the application.

Example: A Realistic Compose Service

Directory:

/opt/whoami/
  compose.yaml
  .env

Compose file:

services:
  whoami:
    image: traefik/whoami:v1.10
    restart: unless-stopped
    ports:
      - "${WHOAMI_PORT}:80"
    healthcheck:
      test: ["CMD-SHELL", "wget -qO- http://localhost || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3

.env file:

WHOAMI_PORT=8080
COMPOSE_PROJECT_NAME=whoami

systemd unit:

[Unit]
Description=Whoami Docker Compose stack
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/whoami
ExecStartPre=/usr/bin/docker compose config --quiet
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecReload=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0
TimeoutStopSec=120

[Install]
WantedBy=multi-user.target

Install it:

sudo systemctl daemon-reload
sudo systemctl enable --now whoami.service

Test:

curl http://localhost:8080

Check status:

systemctl status whoami.service
cd /opt/whoami
docker compose ps

Troubleshooting

Service Starts but Containers Are Not Running

Check systemd:

journalctl -u myapp.service -n 100 --no-pager

Validate Compose:

cd /opt/myapp
docker compose config

Check Docker:

systemctl status docker
docker info

WorkingDirectory Is Wrong

If systemd cannot find your Compose file, confirm:

WorkingDirectory=/opt/myapp

Then check:

ls -la /opt/myapp
ls -la /opt/myapp/compose.yaml

The service runs from WorkingDirectory, not from your current shell directory.

Docker Permission Denied

If the unit runs as root, it can normally access Docker.

If you set User=someuser, that user must be able to access Docker. Usually that means membership in the docker group, or a rootless Docker setup.

Check:

groups someuser

Add the user if appropriate:

sudo usermod -aG docker someuser

Be careful. The Docker group is effectively privileged.

Compose Command Not Found

Find Docker:

command -v docker

Use the full path in the unit:

ExecStart=/usr/bin/docker compose up -d --remove-orphans

If Compose plugin is missing:

docker compose version

Install it using your Docker package source.

Environment Variables Are Missing

Check the Compose config as systemd would see it:

cd /opt/myapp
docker compose config

If systemd needs extra environment variables, use:

EnvironmentFile=-/opt/myapp/.env.systemd

If Compose needs variables for substitution, use:

/opt/myapp/.env

These are related, but not identical.

Containers Do Not Start After Reboot

Check whether the systemd service is enabled:

systemctl is-enabled myapp.service

Enable it:

sudo systemctl enable myapp.service

Check Docker:

systemctl is-enabled docker
systemctl status docker

Check boot logs:

journalctl -u myapp.service -b --no-pager

App Starts Before Database Is Ready

Add a database health check and depends_on with service_healthy.

Also fix the application. It should retry database connections. Infrastructure startup ordering is helpful, but application retry logic is better.

Disk Filled with Docker Logs

Check Docker disk usage:

docker system df

Check large container logs:

sudo du -h /var/lib/docker/containers | sort -h | tail

Configure Docker log rotation in /etc/docker/daemon.json.

Then recreate containers.

Common Mistakes

Mistake 1: Running docker compose up in rc.local

Running docker compose up from rc.local or a login script works until it does not — use a proper systemd unit instead.

Mistake 2: Using Restart=always in systemd and restart: always in Compose

Usually you only need container restart policies in Compose. Avoid two supervisors fighting each other.

Mistake 3: Forgetting –remove-orphans

Service renames and removals can leave old containers behind. Use:

docker compose up -d --remove-orphans

Mistake 4: Using docker compose restart After Config Changes

restart restarts containers. It does not apply all configuration changes.

Use:

docker compose up -d

Mistake 5: Running down -v Without Thinking

This can delete volumes. For stateful services, that can mean deleting data.

Mistake 6: No Backup Before Pull

New images can break. Databases can migrate. Tags can move. Back up first.

Mistake 7: Publishing Every Port

Only publish what the host needs to expose. Internal service-to-service traffic can stay on the Compose network.

For most single-host Linux services, use this pattern:

Compose file:

services:
  app:
    image: example/app:stable
    restart: unless-stopped
    ports:
      - "8080:8080"
    env_file:
      - .env

systemd unit:

[Unit]
Description=MyApp Docker Compose stack
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/opt/myapp
ExecStartPre=/usr/bin/docker compose config --quiet
ExecStart=/usr/bin/docker compose up -d --remove-orphans
ExecReload=/usr/bin/docker compose up -d --remove-orphans
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=0
TimeoutStopSec=120

[Install]
WantedBy=multi-user.target

Enable it:

sudo systemctl daemon-reload
sudo systemctl enable --now myapp.service

Operate it:

sudo systemctl status myapp.service
sudo systemctl restart myapp.service
journalctl -u myapp.service -f
cd /opt/myapp && docker compose logs -f

This pattern is not fancy, and that is the point. Docker Compose is excellent for small, understandable systems, systemd is excellent at starting and stopping host services, and together they give you a reliable single-server deployment model without pretending every project needs a cluster. For container-level commands outside Compose — images, volumes, networks, and cleanup — see the Docker Cheatsheet.

Subscribe

Get new posts on AI systems, Infrastructure, and AI engineering.