How to Automate Docker Container Updates
Manually SSHing into servers to run docker pull && docker restart is a maintenance tax that compounds across a distributed team. Automating container updates with proper health checks and rollback paths means new images ship without anyone touching a terminal.
For remote teams, manual update procedures are especially costly. An update that should take 5 minutes becomes a 30-minute coordination task when the engineer who owns the server is asleep and the engineer who needs the fix is wide awake. Automated updates with notifications solve the coordination problem: images deploy, Slack confirms, and no one needs to wake anyone up.
Approach 1: Watchtower (Pull-Based Auto-Update)
Watchtower watches running containers, polls their registries, and restarts them when new images appear. It’s the simplest path for non-orchestrated Docker hosts.
Run Watchtower alongside your stack:
# docker-compose.yml
version: "3.8"
services:
myapp:
image: ghcr.io/yourorg/myapp:latest
restart: unless-stopped
ports:
- "3000:3000"
labels:
- "com.centurylinklabs.watchtower.enable=true"
watchtower:
image: containrrr/watchtower:latest
restart: unless-stopped
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ~/.docker/config.json:/config.json:ro # registry auth
environment:
# Check every 300 seconds
WATCHTOWER_POLL_INTERVAL: "300"
# Only update containers with the label above
WATCHTOWER_LABEL_ENABLE: "true"
# Wait for containers to fully start before considering healthy
WATCHTOWER_ROLLING_RESTART: "true"
# Send Slack notification on updates
WATCHTOWER_NOTIFICATION_SLACK_HOOK_URL: "https://hooks.slack.com/services/YOUR/HOOK"
WATCHTOWER_NOTIFICATION_SLACK_IDENTIFIER: "watchtower-prod"
WATCHTOWER_NOTIFICATIONS: "slack"
# Clean up old images after update
WATCHTOWER_CLEANUP: "true"
# HTTP API for manual triggers
WATCHTOWER_HTTP_API_UPDATE: "true"
WATCHTOWER_HTTP_API_TOKEN: "your-secret-token"
ports:
- "8080:8080"
Trigger an immediate update via API (useful from CI/CD):
curl -H "Authorization: Bearer your-secret-token" \
http://your-host:8080/v1/update
Pin a container to prevent auto-update:
labels:
- "com.centurylinklabs.watchtower.enable=false"
Watchtower’s label-based opt-in (WATCHTOWER_LABEL_ENABLE: "true") is important in mixed environments. Databases and stateful services should never auto-update without explicit human approval. Apply the enable label only to stateless application containers.
Approach 2: Diun (Notification Only, Manual Pull)
Diun watches registries and sends notifications when new images are available, without auto-updating. Use this when you want human approval before deploying.
# diun/docker-compose.yml
version: "3.8"
services:
diun:
image: crazymax/diun:latest
restart: unless-stopped
volumes:
- ./data:/data
- /var/run/docker.sock:/var/run/docker.sock
environment:
TZ: UTC
LOG_LEVEL: info
DIUN_WATCH_WORKERS: "20"
DIUN_WATCH_SCHEDULE: "0 */6 * * *" # every 6 hours
DIUN_WATCH_JITTER: "30s"
# Slack notifications
DIUN_NOTIF_SLACK_WEBHOOKURL: "https://hooks.slack.com/services/YOUR/HOOK"
DIUN_NOTIF_SLACK_CHANNEL: "#infra-updates"
# Watch all running containers
DIUN_PROVIDERS_DOCKER: "true"
DIUN_PROVIDERS_DOCKER_WATCHBYDEFAULT: "true"
Diun posts to Slack when ghcr.io/yourorg/myapp:latest gets a new digest. Your team can then decide when to pull.
For regulated environments or services with strict change management requirements, Diun-plus-manual-approval is the correct model. The notification lands in #infra-updates, an engineer reviews the changelog and schedules the update during a maintenance window, and the actual deployment remains under explicit human control.
Approach 3: Shell Script with Health Check and Rollback
For teams that want full control without external tools:
#!/bin/bash
# update-container.sh — safe in-place update with rollback
set -euo pipefail
SERVICE_NAME="${1:?Usage: $0 <service-name>}"
COMPOSE_FILE="${2:-/opt/myapp/docker-compose.yml}"
SLACK_HOOK="${SLACK_WEBHOOK_URL:-}"
LOG_FILE="/var/log/container-updates.log"
log() {
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $*" | tee -a "$LOG_FILE"
}
notify_slack() {
local msg="$1"
[[ -z "$SLACK_HOOK" ]] && return
curl -s -X POST "$SLACK_HOOK" \
-H "Content-Type: application/json" \
-d "{\"text\": \"$msg\"}"
}
# Record current image digest before updating
CURRENT_IMAGE=$(docker-compose -f "$COMPOSE_FILE" config | grep "image:" | grep "$SERVICE_NAME" | awk '{print $2}')
CURRENT_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$CURRENT_IMAGE" 2>/dev/null || echo "unknown")
log "Starting update for $SERVICE_NAME (current: $CURRENT_DIGEST)"
# Pull new image
if ! docker-compose -f "$COMPOSE_FILE" pull "$SERVICE_NAME"; then
log "ERROR: Pull failed for $SERVICE_NAME"
notify_slack ":x: Pull failed for \`$SERVICE_NAME\` on $(hostname)"
exit 1
fi
NEW_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$CURRENT_IMAGE" 2>/dev/null || echo "unknown")
if [[ "$CURRENT_DIGEST" == "$NEW_DIGEST" ]]; then
log "No update available for $SERVICE_NAME"
exit 0
fi
log "New image available: $NEW_DIGEST — restarting"
# Restart with health check
docker-compose -f "$COMPOSE_FILE" up -d --no-deps "$SERVICE_NAME"
# Wait up to 60 seconds for health check to pass
TIMEOUT=60
ELAPSED=0
while [[ $ELAPSED -lt $TIMEOUT ]]; do
STATUS=$(docker-compose -f "$COMPOSE_FILE" ps -q "$SERVICE_NAME" | xargs docker inspect --format='{{.State.Health.Status}}' 2>/dev/null || echo "none")
if [[ "$STATUS" == "healthy" || "$STATUS" == "none" ]]; then
log "Container $SERVICE_NAME is up (status: $STATUS)"
notify_slack ":white_check_mark: Updated \`$SERVICE_NAME\` on $(hostname) → ${NEW_DIGEST:0:20}"
exit 0
fi
sleep 5
ELAPSED=$((ELAPSED + 5))
done
# Health check timed out — rollback
log "ERROR: Health check timed out for $SERVICE_NAME — rolling back"
notify_slack ":warning: Update failed for \`$SERVICE_NAME\` — rolling back"
docker tag "$CURRENT_IMAGE" "${CURRENT_IMAGE}-rollback" || true
docker-compose -f "$COMPOSE_FILE" up -d --no-deps --force-recreate "$SERVICE_NAME"
exit 1
Cron job to run nightly at 2 AM:
# /etc/cron.d/container-updates
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/HOOK
0 2 * * * root /usr/local/bin/update-container.sh myapp /opt/myapp/docker-compose.yml >> /var/log/container-updates.log 2>&1
The 2 AM UTC scheduling works well for teams with North America and Europe coverage — it’s overnight for both regions. For teams with Asia-Pacific engineers who need updates during their workday, adjust to a time that avoids everyone’s peak hours.
Approach 4: GitHub Actions Webhook Trigger
Push a new image from CI/CD and have the production host pull immediately:
# .github/workflows/deploy.yml
name: Build and Deploy
on:
push:
branches: [main]
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push image
run: |
docker build -t ghcr.io/${{ github.repository }}:latest .
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker push ghcr.io/${{ github.repository }}:latest
- name: Trigger Watchtower update
run: |
curl -H "Authorization: Bearer ${{ secrets.WATCHTOWER_TOKEN }}" \
https://prod.yourcompany.com:8080/v1/update
Healthcheck Configuration in Docker Images
The shell script rollback approach depends on containers having a proper HEALTHCHECK in their Dockerfile. Without it, Docker always reports status as none and the script can’t detect a failed update:
FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --only=production
# Application must respond 200 on /health within 5 seconds
HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
EXPOSE 3000
CMD ["node", "server.js"]
For Go services:
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -o server ./cmd/server
FROM alpine:3.19
RUN apk add --no-cache curl
COPY --from=builder /app/server /server
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
EXPOSE 8080
CMD ["/server"]
The --start-period parameter is critical: it gives the container time to initialize before health checks begin counting failures. Set it to your typical startup time plus a safety margin. An app that takes 15 seconds to warm up should have --start-period=30s to avoid false rollbacks immediately after a good deployment.
Registry Authentication
For private registries, configure Docker credential helpers before running any update tooling:
# For GHCR
echo "$GITHUB_TOKEN" | docker login ghcr.io -u youruser --password-stdin
# For AWS ECR
aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin \
123456789.dkr.ecr.us-east-1.amazonaws.com
# The resulting ~/.docker/config.json is mounted into Watchtower
For ECR specifically, credentials expire every 12 hours. Run the login command on a cron schedule before Watchtower’s poll interval:
# /etc/cron.d/ecr-auth — refresh ECR credentials every 6 hours
0 */6 * * * root aws ecr get-login-password --region us-east-1 | \
docker login --username AWS --password-stdin \
123456789.dkr.ecr.us-east-1.amazonaws.com >> /var/log/ecr-auth.log 2>&1
Keeping a Changelog of Updates
# Append to a simple update log that the team can review
echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | $SERVICE_NAME | $CURRENT_DIGEST -> $NEW_DIGEST" \
>> /var/log/image-changelog.log
# Query last 20 updates
tail -20 /var/log/image-changelog.log
Post the changelog to Slack weekly so the team has visibility into what changed without checking server logs manually:
#!/bin/bash
# Weekly image update summary
UPDATES=$(grep "$(date -d '7 days ago' +%Y-%m)" /var/log/image-changelog.log | tail -20)
if [[ -n "$UPDATES" ]]; then
curl -s -X POST "$SLACK_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"text\": \"Weekly container update log:\n\`\`\`$UPDATES\`\`\`\"}"
fi
Choosing the Right Approach
Each approach in this guide has a different risk profile and automation level:
| Approach | Automation Level | Human Approval | Best For |
|---|---|---|---|
| Watchtower | Fully automatic | None | Staging environments, non-critical services |
| Diun | Notification only | Required | Production, regulated services |
| Shell script | Configurable (cron) | Optional | Teams wanting full control and custom logic |
| CI/CD webhook | Triggered by code merge | Via PR process | Teams already using GitHub Actions or Drone |
For most remote teams, a two-tier approach works well: Watchtower handles staging automatically so engineers always have a fresh environment to test against, while production uses the CI/CD webhook approach that requires a merge to main to trigger a deployment. This preserves the PR review process as the approval gate for production changes without adding a separate manual deployment step.