Migrating from Docker Compose¶
This guide helps you move stacks currently managed manually, through SSH, or with Ansible to Doco-CD. Read Core Concepts and Getting Started first. This page focuses on the migration itself.
What changes¶
| Topic | Before | After |
|---|---|---|
Who runs compose up | You, over SSH or from a CI job | Doco-CD, from the deployment config |
| Where compose files live | A directory on the host, e.g. /opt/stacks/app | Git, checked out per revision into the artifact storage |
| Where secrets live | Plaintext .env next to the compose file | SOPS-encrypted in Git, an external provider, or a host path outside the repository |
| How a change ships | Edit files on the host, run docker compose up -d | Push to Git, a webhook or poll triggers the deployment |
| Drift | A dead container or a hand-edited file stays that way | Reconciliation reacts to container events, the next deployment overwrites files edited on the host |
| Periodic tasks | Host crontab calling docker exec or docker run | cd.doco.job.* service labels |
| Project name | Derived from the directory Compose ran in | The name field of the deployment config |
1. Inventory the host¶
Do this before you change anything. The goal is one written record per stack.
-
List every Compose project the daemon knows, including stopped ones.
-
Map containers to their project, working directory and compose files.
Compose labels per containerdocker ps -a --format '{{.Names}}' | while read -r c; do docker inspect "$c" --format '{{.Name}} project={{index .Config.Labels "com.docker.compose.project"}} working_dir={{index .Config.Labels "com.docker.compose.project.working_dir"}} config_files={{index .Config.Labels "com.docker.compose.project.config_files"}}' done -
List every bind mount, so you can separate paths that will move from paths that will not.
-
Write down per stack:
- Compose project name, exactly as
docker compose ls -aprints it. - Compose file(s) and override files, in the order they are applied.
-
.envand everyenv_file. - Services that set
container_name. -
restartpolicies. - Networks shared with other stacks.
- Named volumes and bind mounts, relative and absolute.
- Image tags: floating (
latest) or pinned. - Host crons, systemd units and scripts that touch these containers.
- Secrets on disk and who reads them.
- Published ports.
- Containers started by hand with
docker run, outside Compose.
- Compose project name, exactly as
2. Decide the repository layout¶
| Layout | Use when | Cost |
|---|---|---|
| One repository per host | Hosts are unrelated, blast radius must stay small | Shared compose snippets get duplicated |
One repository, one target per host (.doco-cd.<target>.yml) | Many hosts, mostly the same stacks | Every host sees every commit, target must be set per instance |
| One repository per team or per blast radius | Access control follows teams | A stack that moves between teams moves between repositories |
Inside a repository, give each stack its own directory and list them as separate YAML documents in one deployment config.
name: proxy
working_dir: proxy
---
name: app
working_dir: app
env_files:
- .env
- prod.env
The project name is the adoption key
name becomes the Compose project name. It must match the project name the stack runs under today, otherwise Doco-CD creates a second set of containers next to the old ones. Renaming a deployed project later is not possible.
Shared networks: declare them external: true in every stack and create them once on the host, or own them in one small bootstrap stack.
Image tags:
- Doco-CD deploys what Git says.
- A floating tag such as
latestin Git does not redeploy when the registry moves, unless you setforce_image_pullor you pin it to a digest (see below). - A tag such as
1.4.2is a readable registry label, but a publisher can move it to a different image. A digest is a SHA-256 identifier for an image manifest that selects a specific image. -
To find a digest, run
docker buildx imagetools inspect ghcr.io/example/app:1.4.2or copy it from your registry's image details. Add the reported digest after@in the Composeimagevalue:Replace
<digest>with the 64-character value reported aftersha256:. Keep the tag and digest together when upgrading. Renovate can open pull requests with updated image references.
Keep compose files identical across environments. Put the host-specific values in environment and env_files of the deployment config instead. Both only feed Compose variable interpolation, nothing reaches a container by itself. The Compose service still needs environment: or env_file: entries that reference the values:
services:
app:
image: ghcr.io/example/app:${APP_IMAGE_TAG}
environment:
LOG_LEVEL: ${APP_LOG_LEVEL}
3. Fix paths and data before the first deploy¶
Every revision is served from its own immutable artifact directory, so a relative bind mount resolves to a path that changes with the revision.
Never keep persistent data behind a relative bind mount
Data a container writes into the artifact storage is lost as soon as the service is deployed from a new revision. Move it to a named volume or an absolute host path before the first Doco-CD deployment.
| Mount today | Do this |
|---|---|
| Relative bind mount holding data | Convert to a named volume or an absolute host path. |
| Relative bind mount holding config | Keep it. The service is recreated when the file content changes, or add recreate.ignore plus a signal for a reload in place. |
| Absolute host path | Unchanged, the data survives. |
| Named volume | Survives while project name and volume name stay the same. Set name: on the volume to decouple it from the project name. |
Relative env_file | Keep it, it is read from the artifact of the revision. |
services:
db:
image: ghcr.io/example/db:1.2.3
volumes:
- db-data:/var/lib/db # named volume, survives
- /srv/backups:/backups # absolute host path, survives
- ./initdb:/initdb:ro # relative, read-only config
volumes:
db-data:
name: app-db-data
Copy the data before you change the mount
A new named volume or a new absolute path starts empty. A service deployed against it runs with empty state while the old files stay at the old path.
Move data from a relative bind mount, per service:
- Stop the service:
docker compose stop db. - Make a backup and verify it, e.g.
tar -C /opt/stacks/db -czf /srv/backups/db-data.tgz data && tar -tzf /srv/backups/db-data.tgz > /dev/null. -
Copy the data, keeping ownership and permissions.
-
Verify the copy:
diff -r /opt/stacks/db/data /srv/db-data, or compare a named volume recursively:Verify a named-volume copydocker run --rm \ -v /opt/stacks/db/data:/from:ro \ -v app-db-data:/to:ro \ alpine diff -r /from /toNo output and an exit status of
0mean the directories match. 5. Point the compose file at the new volume or path, then trigger Doco-CD. 6. Delete the old directory only after the service ran on the new mount.
Env files:
env_filesare parsed with the same dotenv engine as Docker Compose, see Dotenv File Format.- Audit existing env files for a literal
$, e.g. in password hashes: the engine interpolates${VAR}, so quote the value with single quotes or write$$. - If a stack uses
includewith a remote compose file, setproject_directory: ., see Resolving.envand other relative paths.
Secrets, never commit plaintext:
| Option | Notes |
|---|---|
| SOPS with age | One key on the controller, files encrypted in Git. |
| SOPS with a cloud key service | No key on disk. At least one SOPS_* environment variable must still be set as a marker. |
| External secret providers | Values are fetched at deployment time and never stored in Git. |
PASS_ENV | Passes the controller's own environment into interpolation, see App Configuration. Use with care. |
| A file under an absolute host path outside the repository | Simplest, but not covered by Git history. |
4. Install the controller¶
Take the base docker-compose.yml from Getting Started, then decide:
- Trigger: polling needs no inbound port, webhooks are faster. Both can run together.
- Docker access: the socket grants full control, a socket proxy restricts it. With a proxy or a remote daemon, set
DATA_HOST_PATH. - Data mount: back
/datawith a named volume or a host path. It holds the artifacts and the decrypted secrets, seeDATA_MOUNT_PATH. - Pin the Doco-CD image tag instead of
latest, and keeprestart: unless-stopped. - Keep the image's health check (
test: ["CMD", "/doco-cd", "healthcheck"]). - Set
LOG_LEVEL: debugfor the first deployments, see Runtime Settings. - Set
targetper host if you use one repository with several targets, so this instance only reads its own config file.
- url: https://git.example.com/example/deployments.git
reference: refs/heads/main
interval: 3m
target: prod # reads .doco-cd.prod.yml
The file is only read when POLL_CONFIG_FILE names it and it is mounted into the container:
services:
app:
image: ghcr.io/kimdre/doco-cd:latest
environment:
POLL_CONFIG_FILE: /poll-config.yaml
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./poll-config.yaml:/poll-config.yaml:ro
- data:/data
volumes:
data:
See With POLL_CONFIG_FILE for the full example, Poll Settings for the field list, and Local Filesystem Polling if the repository lives on the same host.
5. Cut a stack over¶
One stack at a time, least critical first.
-
Check the preconditions.
- The project renders from the repository:
docker compose -f app/docker-compose.yml config. This local command does not read.doco-cd.yml. If itsenvironmentorenv_filesprovide Compose interpolation values, supply the same values in your shell or with Docker Compose's--env-fileoption. Otherwise, this check can resolve values differently from Doco-CD. -
nameequals the running Compose project name. - No
container_namecollides with another project. Container names are unique per Docker host:docker ps -a --format '{{.Names}}'. - Data mounts fixed as in section 3.
- Secrets reachable by the controller.
-
remove_orphansdefaults totrue, so containers of that project which are not in the compose files are removed.
- The project renders from the repository:
-
Pick the adoption path.
Situation Steps Same project name Commit and let Doco-CD deploy. Named volumes and absolute host paths are kept. Different project name Pin every named volume to its existing name with name:(docker volume ls), rundocker compose downwithout-von the old project, then deploy.No Compose project, started with docker runThere is no project for docker compose downto remove. Pin the volumes as above, thendocker stop <container>anddocker rm <container>without-vfor each one, then deploy.Warning
Skipping the stop and remove in the last two cases leaves two sets of containers that fight over ports, names and volumes.
docker rmwithout-vkeeps every volume on disk, named and anonymous,docker rm -vremoves the anonymous ones.Anonymous volumes are not reused
A volume with a 64 character hex name belongs to one container only. The replacement Compose service gets a fresh volume, so the data looks gone. Find them before the cutover:
docker inspect <container> --format '{{range .Mounts}}{{if eq .Type "volume"}}{{.Name}} {{.Destination}}{{println}}{{end}}{{end}}'Then either declare a named volume in the compose file with
name:set to that hex name, or copy the content into a new named volume as in section 3. -
Trigger the deployment: wait for the poll interval, push a commit to a configured webhook, or call the API.
curl --request POST \ --url 'https://cd.example.com/v1/api/poll/run?wait=true' \ --header 'content-type: application/json' \ --header 'x-api-key: your-api-key' \ --data '[{"url": "https://git.example.com/example/deployments.git", "target": "prod"}]'See REST API for the request body and Authentication for
API_SECRET. The API uses thetargetin this request body and ignores the mounted poll config. Use the target configured for this host, or omit it only when deploying.doco-cd.yml. -
Verify.
-
docker compose lsshows the project. - The containers carry the Doco-CD labels:
docker inspect <container> --format '{{index .Config.Labels "cd.doco.deployment.name"}}'. -
cd.doco.deployment.target.shamatches the commit you pushed. - The controller log shows the deployment finishing.
- A notification arrived, if configured.
What gets recreated on adoption
Plan for every service of the stack to be recreated on the first deployment, and pick the time window accordingly. Services with relative bind mounts or relative
env_fileentries are always recreated, because those host paths move into the artifact storage. Any other service is recreated when its resolved configuration differs from the running container. From the second deployment on, a service stays on its old artifact while its files are unchanged, see Unchanged services. -
-
Confirm the second trigger is a no-op.
Nothing should be recreated. Check the run status or the
pre-deploystage outcome:Recent skipped runscurl --header 'x-api-key: your-api-key' \ 'https://cd.example.com/v1/api/runs?status=skipped&limit=20'The same shows up in Prometheus metrics as
doco_cd_deployment_stage_duration_seconds{stage="pre-deploy",outcome="skipped"}. -
Know how to roll back.
Push a revert commit, that is the normal path.
Moving the branch back to an older commit is skipped
When the revision a run resolves to is an ancestor of the commit that is already deployed, Doco-CD treats the run as stale and skips it, so a newer state is never silently reverted. Use a revert commit, or set
force_recreate, which bypasses that guard. Removeforce_recreateagain after the rollback deployment, otherwise every following deployment and poll recreates the services.Keep the old compose files and env files on the host until the stack has survived one normal change.
-
Remove the leftovers on the host.
- The old compose directory.
- systemd units and scripts running
docker compose up. - Host crons for this stack, see section 6.
- Deploy keys and SSH access of the old pipeline.
6. Replace crons, scripts and helpers¶
| You had | Use instead |
|---|---|
Host cron running docker exec or docker run | Scheduled job labels on the service: cd.doco.job.enabled and cd.doco.job.schedule. |
| A backup cron that needs the app stopped | cd.doco.job.stop_services, which takes service or project/service. |
docker system prune -a cron | docker image prune, plus the built-in artifact garbage collection. |
| Deploy scripts running migrations | Init containers, sidecars or Compose lifecycle hooks, see Pre- / Post-Deployment Scripts. Doco-CD has no shell. |
| Watchtower-style tag watching | Pinned tags plus Renovate, so the change is a commit. |
| An autoheal container | Reconciliation on the unhealthy, die or oom events. |
systemd unit running docker compose up -d at boot | Compose restart policies. A poll job with an interval also polls at startup, see Cron schedules. |
docker compose up after editing files on the host | Nothing. Commit the change instead, the next deployment overwrites host edits. |
Scheduled jobs have rules worth knowing before you convert a cron:
- The service
restartpolicy must be unset ornoin standalone Compose, see Configuration. - A job never runs as a side effect of a deployment, see Execution modes.
- In
restartmode the container is created but not started, so it sits increatedbetween runs.
Pruning removes idle job containers
A prune that removes stopped containers also removes the idle created containers of restart mode jobs. Prune images only, or scope the prune with filters.
7. After the migration¶
- Turn on notifications so a failed deployment is not silent.
- Scrape the Prometheus metrics endpoint.
- Enable
GIT_COMMIT_STATUSso a commit shows whether it is deployed. - Restrict production with sync windows, configured on the controller, not in the deploy config.
- Enable reconciliation per stack, starting with the
unhealthyevent. - Review the artifact garbage collection defaults against your disk budget.
- Upgrade the controller by bumping the pinned tag and reading the release notes first. Some releases change on-disk layout, for example v0.120.0.
8. Migration checklist¶
- Inventory every Compose project, path, secret and side channel on the host.
- Decide the repository layout and where the target files live.
- Note the current Compose project name per stack, it becomes
name. - Move persistent data off relative bind mounts.
- Pin named volumes with
name:where the project name may differ. - Declare shared networks
external: trueand create them once. - Pin image tags and set up Renovate.
- Move host-specific values into
environmentandenv_files. - Encrypt or externalize every secret, remove plaintext from the repository.
- Install the controller with a persistent
/datamount and a pinned tag. - Choose polling, webhooks or both, and set
targetper host. - Start with
LOG_LEVEL: debug. - Per stack: check preconditions, including
container_namecollisions. - Per stack:
docker compose downwithout-vwhen the project name changes. - Per stack: commit, trigger, verify labels and commit SHA.
- Per stack: confirm the second trigger changes nothing.
- Per stack: keep old compose and env files until one normal change succeeded.
- Convert host crons to scheduled job labels.
- Replace
docker system prune -acrons withdocker image prune. - Replace deploy scripts with init containers, sidecars or lifecycle hooks.
- Remove old systemd units, scripts and deploy keys.
- Enable notifications, metrics and commit status.
- Add sync windows and reconciliation where they are needed.