Skip to content

Self-Updating Doco-CD

doco-cd can deploy the stack that contains its own container, from a single instance. A push that changes the doco-cd compose file is a deployment like any other.

Set SELF_UPDATE_ENABLED to true (disabled by default). With this setting disabled, a deployment that would replace this container is refused with an error rather than attempted.

Pin the image

Use image: ghcr.io/kimdre/doco-cd:<version>@sha256:<digest> and let a bot such as Renovate open the bump. With a moving tag like latest, doco-cd upgrades itself whenever the tag moves and a breaking change lands unreviewed.

A demo setup can be found in examples/self-updating.

Requirements

A doco-cd stack must be defined in the deploy repository with the following rules:

  • The doco-cd service needs a restart policy (always, unless-stopped or on-failure). Crash recovery depends on Docker bringing the container back.
  • It must run exactly one container: scale or replicas is not supported.
  • An existing named volume's configuration or name cannot change during a self-update. Compose may replace the volume, but rollback cannot restore its data. Handle volume migrations separately, with a backup.
  • The self stack must be on the default Docker context and must not come from an OCI source.
  • Recreating a project network requires Docker's default bridge network. The applier stays on it while Compose recreates the project networks. Other self-updates do not use it.
  • Give the service a healthcheck. Without one doco-cd falls back to running doco-cd healthcheck inside the new container, which is slower.

Keep Docker socket proxies outside the self-updating project

If doco-cd accesses Docker through a socket proxy, run the proxy in a separate Compose project. Self-update deploys the other services in doco-cd's project before replacing doco-cd; Compose may recreate the proxy even when its configuration has not changed. That cuts off Docker API access and fails the deployment. depends_on and restart policies do not prevent this.

For a Unix-socket proxy, share a named socket volume between the projects and declare it external: true in both Compose files. Start the proxy before doco-cd and update it separately. The same lifecycle risk applies to TCP proxies.

Bootstrap

If you already have an existing doco-cd instance, skip to Migrating an existing instance.

The first container must already carry the labels of a managed stack, otherwise the running instance cannot recognize the stack as its own. Run the one-shot bootstrap once per host:

Bootstrap script: bootstrap.sh
#!/usr/bin/env sh
# One-shot bootstrap: creates the doco-cd stack from the repo and exits.
# After this, doco-cd manages its own stack like any other application.
# Run this script once per host and never again.
set -eu

# Use the same release version as infra-repo/doco-cd/compose.yaml.
# https://github.com/kimdre/doco-cd/releases
IMAGE="ghcr.io/kimdre/doco-cd:0.125.1"
DATA_VOLUME="doco-cd_data"
REPO_URL="https://github.com/example/infra.git"

docker volume create "$DATA_VOLUME" >/dev/null

# Add other app settings via environment variables as needed,
# e.g. GIT_ACCESS_TOKEN or SOPS_AGE_KEY
docker run --rm \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v "$DATA_VOLUME":/data \
  -e SELF_UPDATE_ENABLED=true \
  -e GIT_ACCESS_TOKEN="${GIT_ACCESS_TOKEN:-}" \
  -e POLL_CONFIG="- url: $REPO_URL
  reference: main" \
  "$IMAGE" apply-self --bootstrap

It clones the repository, deploys every configured target once, and exits. What remains is a doco-cd container created by the normal deploy path, with the correct com.docker.compose.* and cd.doco.* labels. Replace the image reference when using a different release in the repository and the bootstrap script.

If a sync window blocks the bootstrap deployment, nothing is deployed. Doco-CD logs a warning with the blocking windows and the time they open and the bootstrap exits with a non-zero exit code. Run it again once the window allows it, or set manual_sync on the blocking windows.

Migrating an existing instance

An instance that is already running does not need the bootstrap. doco-cd recognizes its own stack by the com.docker.compose.project and com.docker.compose.service labels of the running container, so a takeover only requires the repository to describe the same compose project.

  1. Recreate the running instance one last time, the way it was started:
    • Set the image to a release that supports self-update.
    • Set SELF_UPDATE_ENABLED=true, a restart policy and a healthcheck.
  2. Add the doco-cd stack to the repository. name in .doco-cd.yml must equal the compose project name of the running container and the service name must match too. Otherwise the deploy creates a second doco-cd next to the running one.
  3. In that compose file mount the same data volume as external: true. A different volume starts the successor with an empty repository cache and journal. Keep the rest identical to what runs, so the takeover is the only change.
  4. Push the changes to your deploy repository. The next time doco-cd does a deployment, it deploys the stack, finds its own container in it and hands over to a successor created from the repository. From then on the repository is the only source of truth.

With the two-instance setup, remove the updater stack in the same commit and enable self-update on the main instance only.

Strategies

SELF_UPDATE_STRATEGY selects how the container is replaced. The default auto uses scale_out unless the compose file rules it out.

scale_out (no downtime)

  1. Every other service in the stack deploys normally first.
  2. doco-cd creates a second container from the new configuration and starts it. The running instance keeps serving throughout.
  3. The running instance waits for the new container to report healthy, bounded by the deploy config's timeout.
  4. On success it finishes its in-flight work, records the handover on the data volume and waits to be stopped. The new instance removes it, then sends the deployment notification and completes the check run or commit status.
  5. On failure the new container is removed. Nothing else changed, and the running instance reports the failure and completes the check run or commit status with the failure outcome.

scale_out is impossible when the service sets container_name, publishes host ports, uses network_mode: host, or when a project network must be recreated. Two containers cannot share a name or a host port. Drop both from the doco-cd service and reach the API through a reverse proxy on the compose network. Recreating a project network through the applier also briefly interrupts other services attached to it and a failed update restores their previous state.

applier (short restart)

doco-cd clones its own container into a throwaway container running doco-cd apply-self. The clone recreates the doco-cd service from outside, waits for health, and exits. If the new version never becomes healthy, the clone restores the previous container from a snapshot taken before the attempt. Whichever instance runs afterwards, the replacement or the restored one, sends the deployment notification and completes the check run or commit status.

Expect a brief interruption while the replacement starts and becomes healthy. Webhook requests during the interruption may receive an 503 error. The next poll catches up with missed changes.

Failure handling

Every handover is journaled on the data volume, so a crash at any point is resolved on the next boot: whichever instance comes up finishes the handover or reverses it.

The journal preserves the exact reporting backend and, for native GitHub Checks, the original check-run target, without storing credentials. For successful native checks, it also preserves the predecessor's deployment summary, so the finishing instance reports the original stack, target, revision, and detected changed services rather than rebuilding those details from its own configuration. The finishing instance resolves its credentials and completes that original report even if its configuration sets GIT_COMMIT_STATUS=false. It must retain credentials and permissions for that backend. Older journals without a check-run target always finish legacy commit statuses, even when the finishing instance now uses GitHub App authentication; keep the App's Commit statuses: Read and write permission until those handovers finish.

A self-update that fails is recorded against that commit and not retried until a new commit arrives, so a broken version cannot loop. The reason appears in the logs and in the failure notification.

Docker restarts a crashing applier at most three times while the running instance still serves before the update fails. Once the running instance has handed over, Docker keeps restarting the applier until it finishes or restores the previous container.

Once admission closes for a handover, new webhook and poll work is refused until service resumes. The predecessor waits for all in-flight deployments without a fixed timeout, so this can take longer than a few seconds. Webhook senders should retry requests that receive 503.

Swarm

Docker Swarm already replaces a service through a rolling update performed by the swarm manager, so a Swarm stack containing doco-cd updates itself without any of this.

Alternative: two instances

The older approach runs two instances that deploy each other: a main instance handling your deployments, and an updater instance whose only job is redeploying the main one. It still works and needs no SELF_UPDATE_ENABLED, at the cost of a second container, a second clone of every repository and a second scheduler to keep disabled.

.doco-cd.yml
name: doco-cd-updater
reference: main
working_dir: ./doco-cd
compose_files:
  - compose.updater.yaml
force_recreate: true
.doco-cd.updater.yml
name: doco-cd
reference: main
working_dir: ./doco-cd
compose_files:
  - compose.main.yaml

The updater polls with target: updater and deploys the main instance. Set SCHEDULER_ENABLED to false on the updater so both instances do not pick up the same scheduled jobs. If Docker reports a container name conflict during the handover, set force_recreate: true for that stack or remove the old container once by hand.