Skip to main content
RunBook Academy

Docker & ContainersXXXVI · AutomationAutomation

Automation with shell, systemd, and Ansible

Intermediate⏱ ~30 min

What you'll learn

  • State the idempotence problem and recognise it in a shell script
  • Write a systemd unit that supervises a container rather than the Docker CLI
  • Avoid the double restart policy that makes systemd and Docker fight
  • Choose between shell, systemd and Ansible on the basis of what each guarantees
  • Verify an automation actually converged, rather than that it exited zero

Prerequisites

None — start here.

Verified against Docker Engine 29.x · Docker Engine 28.x · Docker Compose 2.x · containerd 2.x · runc 1.2.x · BuildKit 0.20+ · Linux kernel 5.15+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-12

Not yet marked complete on this device.

Automation is usually described as “replacing manual steps with a script”. That description is why so much of it is dangerous. The property that matters is not that a machine runs the steps instead of a person; it is that running the steps again is safe.

Almost every Docker automation defect traces back to that one property.

The idempotence problem

Here is the entire problem in two commands.

Service impact possiblerun it twice
$ docker run -d --name web -p 8080:80 nginx:1.27
docker run -d --name web -p 8080:80 nginx:1.27
a3f1c2b8e4d9...
docker: Error response from daemon: Conflict. The container name "/web" is
already in use by container "a3f1c2b8e4d9". You have to remove (or rename)
that container to be able to reuse that name.

Illustrative output

The second invocation fails. Under set -e — which every competent shell script has — the script aborts, and every step after it never runs. Your “deploy” script now half-deploys, and which half depends on whether the container happened to exist.

Now drop the --name to avoid the conflict, which is the usual instinct:

Service impact possiblethe worse fix
$ docker run -d -p 8080:80 nginx:1.27
docker run -d -p 8080:80 nginx:1.27
b71d0aa4f52c...
docker: Error response from daemon: driver failed programming external
connectivity on endpoint quirky_bose: Bind for 0.0.0.0:8080 failed: port is
already allocated.

Illustrative output

Still fails, for a different reason, and now you have an anonymous container you cannot find by name. Remove the port publication too and it finally “succeeds” — by leaving two identical containers running, both connected to the application network, both registered in Docker’s embedded DNS under the same service alias, splitting traffic between an old build and a new one.

That is the failure that costs a night: the deploy script exits zero, the dashboards look fine, and roughly half of requests hit an image from three weeks ago.

docker run is not idempotent, and it has no flag that makes it so. There is no --if-not-exists, no --replace, no declarative mode. This is not an oversight — docker run is an imperative command that creates a thing. The gap is real, and everything in the rest of this lesson exists to fill it.

Three grades of shell idempotence

Shell can approximate idempotence. It is worth knowing the grades, because the difference between them is the difference between a script that works and a script that hides failures.

Configuration changefrom worst to acceptable
NET=app-net

# Grade 1: swallow every error. Never do this.
# A typo, a permission failure and a daemon outage are all "fine" now.
docker network create "$NET" 2>/dev/null || true

# Grade 2: check first. Better, but it is a check-then-act race, and
# it still cannot detect that the network exists with the WRONG subnet.
docker network inspect "$NET" >/dev/null 2>&1 || docker network create "$NET"

# Grade 3: check first, and check the properties you care about.
if docker network inspect "$NET" >/dev/null 2>&1; then
SUBNET=$(docker network inspect "$NET" \
  --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}')
if [ "$SUBNET" != "192.0.2.0/24" ]; then
  echo "FAIL: $NET exists with subnet $SUBNET, expected 192.0.2.0/24" >&2
  exit 1
fi
else
docker network create --subnet 192.0.2.0/24 "$NET"
fi

Grade 1 is the one you will find in most blog posts, and it is worse than no error handling at all: it converts every failure, including the ones you desperately want to know about, into silence.

Grade 3 is what an Ansible module does internally, and writing it out makes the cost obvious. That is roughly fifteen lines to make one resource converge. A Compose stack has containers, networks, volumes and images. Doing this properly in shell for all of them is several hundred lines of error-prone code that duplicates, badly, something that already exists.

That is the honest argument for the tools. Not “shell is unprofessional” — shell is fine — but that convergence is a solved problem and re-solving it per-script is where the bugs live.

systemd for containers

systemd’s contribution is different from Ansible’s. Ansible converges state when you run it; systemd keeps state converged, restarts things that die, orders startup, and gives you systemctl status and journalctl as the interface. For a single host, that is often exactly the missing piece.

It also has one trap that catches nearly everyone.

The correct shape runs the container in the foreground so that the process systemd supervises is the one whose lifetime matches the container.

Configuration change/etc/systemd/system/web.service
[Unit]
Description=web (nginx container)
After=docker.service network-online.target
Requires=docker.service
StartLimitIntervalSec=300
StartLimitBurst=5

[Service]
Type=simple
Restart=always
RestartSec=10
TimeoutStartSec=120
TimeoutStopSec=70

# Remove a container left behind by an unclean shutdown. The leading "-"
# means systemd ignores a non-zero exit, which is the normal case: on a
# clean start there is nothing to remove.
ExecStartPre=-/usr/bin/docker rm -f web

# Foreground. No -d. --rm so a crashed container does not block the next
# start. --sig-proxy is on by default, so SIGTERM reaches PID 1 in the
# container.
ExecStart=/usr/bin/docker run --rm --name web \
--publish 127.0.0.1:8080:80 \
--read-only \
--tmpfs /var/cache/nginx \
--tmpfs /var/run \
--security-opt no-new-privileges=true \
nginx:1.27

# docker stop sends SIGTERM then SIGKILL after its own grace period.
ExecStop=/usr/bin/docker stop --timeout 30 web

[Install]
WantedBy=multi-user.target

Directive by directive, because each of these is a bug you would otherwise find in production:

  • After=docker.service and Requires=docker.service. After= orders; Requires= creates the dependency. You need both. After= alone means “if docker is being started, start after it” — on a boot where something masked docker.service, your unit starts anyway and fails.
  • Type=simple with a foreground docker run. The supervised process is the CLI, which now stays alive for as long as the container does and proxies signals to it. docker run -d is what breaks this.
  • ExecStartPre=-/usr/bin/docker rm -f web. After a hard power loss the container record survives and the next docker run --name web fails with a name conflict — the idempotence problem, arriving via a different door. The leading - makes the ordinary “no such container” exit non-fatal.
  • RestartSec=10 with StartLimitBurst=5 over StartLimitIntervalSec=300. Without a rate limit, a container that fails to start restarts forever and saturates the disk with image-layer churn and journal writes. With it, five failures in five minutes puts the unit into failed and stops, which is a state a human can see. Note the two StartLimit* directives belong in [Unit], not [Service] — misplacing them is silent, and they simply do not apply.
  • TimeoutStopSec=70 against docker stop --timeout 30. systemd’s stop timeout must exceed Docker’s, or systemd SIGKILLs the CLI while Docker is still waiting out its own grace period, and the container is orphaned mid- shutdown. Leave headroom.

Compose stacks under systemd

A Compose stack has many containers, so the foreground trick does not apply. The accepted shape is different, and its limitations should be stated plainly.

Configuration change/etc/systemd/system/myapp-stack.service
[Unit]
Description=myapp Compose stack
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=/srv/myapp
ExecStartPre=/usr/bin/docker compose config --quiet
ExecStart=/usr/bin/docker compose up --detach --remove-orphans --wait
ExecStop=/usr/bin/docker compose down
TimeoutStartSec=600

[Install]
WantedBy=multi-user.target
  • Type=oneshot with RemainAfterExit=yes. The command exits, and systemd keeps the unit active anyway. That is the whole point of this pattern.
  • ExecStartPre=docker compose config --quiet. Parses and validates the Compose file, including variable interpolation, and fails the unit before anything is touched. Without it, an unset variable produces a stack that comes up misconfigured at boot with no obvious error.
  • --wait. Blocks until services are running and, for services with a healthcheck, healthy. Without it docker compose up -d returns as soon as the containers are created, so systemd reports the unit active while the application is still starting — and any unit ordered After= this one starts too early.
  • --remove-orphans. Removes containers from services you deleted from the Compose file. Otherwise they keep running, forever, invisible to docker compose ps.

Ansible

Ansible’s contribution is the third thing: convergence across many hosts, expressed declaratively, with a change record.

Configuration changeroles/docker_host/tasks/main.yml
- name: Install Docker Engine
ansible.builtin.apt:
  name:
    - docker-ce
    - docker-ce-cli
    - containerd.io
    - docker-compose-plugin
  state: present
  update_cache: true

- name: Configure the Docker daemon
ansible.builtin.copy:
  content: "{{ docker_daemon_config | to_nice_json }}"
  dest: /etc/docker/daemon.json
  owner: root
  group: root
  mode: '0644'
  validate: 'python3 -c "import json,sys; json.load(open(sys.argv[1]))" %s'
notify: restart docker

- name: Ensure Docker is running and enabled
ansible.builtin.systemd_service:
  name: docker
  state: started
  enabled: true
  daemon_reload: true

- name: Run the application container
community.docker.docker_container:
  name: web
  image: "nginx:{{ nginx_version }}"
  state: started
  restart_policy: unless-stopped
  published_ports:
    - '127.0.0.1:8080:80'
  memory: 512M
  read_only: true
  security_opts:
    - 'no-new-privileges=true'

Two lines carry more weight than they look:

  • validate: on the daemon.json copy. Ansible runs the validation command against the staged file and refuses to install it if the command fails. Without this, a templating mistake writes malformed JSON, the handler restarts Docker, and dockerd refuses to start — taking every container on the host with it. This one line is the difference between a failed play and an outage.
  • community.docker.docker_container rather than docker_container. The fully-qualified name is not pedantry: short names resolve through a search path that depends on which collections are installed, and a playbook that works on the control node and fails in CI is usually this.

A backup script, corrected

The volume-backup script is the most-copied piece of Docker shell automation, and the common version has two real defects.

Service impact possiblebackup-volumes.sh
#!/bin/bash
# Back up named Docker volumes. Anonymous volumes are deliberately skipped:
# their names are 64-hex strings that mean nothing on restore.
set -euo pipefail

STAMP=$(date -u +%Y-%m-%d)
BACKUP_DIR="/var/backups/docker/$STAMP"
S3_PREFIX="s3://myorg-backups/docker/$STAMP"
mkdir -p "$BACKUP_DIR"

# Filter out anonymous volumes: 64 lowercase hex characters, nothing else.
docker volume ls --format '{{.Name}}' \
| grep -Ev '^[0-9a-f]{64}$' > "$BACKUP_DIR/volumes.txt"

while read -r VOL; do
[ -n "$VOL" ] || continue
echo "Backing up volume $VOL"
docker run --rm \
  --network none \
  --user 0:0 \
  -v "$VOL":/source:ro \
  -v "$BACKUP_DIR":/backup \
  alpine:3.20 \
  tar czf "/backup/$VOL.tar.gz" -C /source .
done < "$BACKUP_DIR/volumes.txt"

# BACKUP_DIR is absolute, so syncing it directly would produce an S3 key of
# s3://bucket/docker/2026-08-12/var/backups/docker/2026-08-12/... Sync the
# directory CONTENTS to the prefix instead.
aws s3 sync "$BACKUP_DIR/" "$S3_PREFIX/"

# Verify each archive is readable before declaring success. A tar that was
# truncated by a full disk exits zero on creation and fails here.
for ARCHIVE in "$BACKUP_DIR"/*.tar.gz; do
tar tzf "$ARCHIVE" >/dev/null || { echo "CORRUPT: $ARCHIVE" >&2; exit 1; }
done
echo "Backup verified: $(ls -1 "$BACKUP_DIR"/*.tar.gz | wc -l) archives"

Choosing between the three

ShellsystemdAnsible
Converges stateOnly what you writeNo — it starts and supervisesYes, per resource
Survives rebootNoYesNo (it runs when you run it)
Restarts on failureNoYesNo
Multiple hostsCopy it aroundNoYes, with inventory
Records what changedNoJournal, per startYes, changed/ok per task
Dry runNoNo--check --diff

They are complements, not alternatives, and the usual mature arrangement uses all three: Ansible converges the host and writes the unit files, systemd runs and supervises the workloads, and shell handles the genuinely one-shot things that have no state to converge.

  1. Write the operation once, by hand, and record every command and its output. You cannot automate a procedure you have not performed.
  2. Make it idempotent, then prove it by running it twice on a scratch host and diffing the state afterwards. Running twice is the test, not reading the code.
  3. Give it a dry run. --check in Ansible, docker compose config for a stack, or an explicit DRY_RUN guard in shell. An automation you cannot rehearse is one you will only ever run in anger.
  4. Take a lock before touching the daemon, so two automations cannot race.
  5. Verify convergence, not exit status. Exit zero means the commands ran. Query the resulting state and compare it to what you intended.
  6. Fail loudly. 2>/dev/null || true is how a broken automation runs green for six months.

Knowledge check

Knowledge check · 6 questions

  1. Q1. A systemd unit uses `ExecStart=/usr/bin/docker run -d --name web nginx:1.27` with `Type=simple`. What goes wrong?

  2. Q2. Why is `docker network create app-net 2>/dev/null || true` a poor idempotence pattern?

  3. Q3. Which are true when a container has both `--restart unless-stopped` and a systemd unit with `Restart=always`? Select all that apply.

  4. Q4. In a Compose unit, why add `--wait` to `docker compose up --detach`?

  5. Q5. A container started by a foreground `docker run` in a systemd unit is parented into a containerd shim, so `MemoryMax=` in the unit does not constrain it.

  6. Q6. Which Ansible flag pair lets you see what a play would change to a Docker host before it changes it?

Passing score: 75%. Answers are checked in this browser.