GitOps without Kubernetes: Docker Deployments with doco-cd, Renovate and SOPS

How I deploy Docker Compose stacks to several hosts from Git, version encrypted secrets and automate image updates without giving up control.

13 min read
  • #Self Hosting
  • #Docker
  • #Gitops
  • #Security

I do not need Kubernetes in my homelab. Most services consist of one or two containers, stay on the same host permanently and do not need to be distributed automatically across several nodes. What I was missing nevertheless was traceable deployment.

For a long time, my Compose files lived directly on the individual servers. An update meant logging in, changing into the right directory, running docker compose pull and then hoping I had not forgotten to make the same change somewhere else. The configuration was reproducible in some loose sense, but not really versioned. Image updates arrived through different mechanisms, secrets lived beside the Compose files and rollback often meant remembering what the previous state had been.

My current deployment model separates those responsibilities cleanly:

  • doco-cd applies the desired state described in Git to the Docker hosts.
  • Renovate finds new image versions and opens pull requests for them.
  • SOPS encrypts secrets so that they can be versioned as well.
  • age provides the comparatively simple key mechanism used by SOPS.

The examples in this article are shortened in places, but they correspond to the actual structure.

The complete flow

The deployment path looks like this:

                    Pull Request
Renovate  ------------------------------>  GitHub
                                               |
                                               | Merge into master
                                               v
                                    GitHub webhooks per host
                                      /         |         \
                                     v          v          v
                                  doco-cd    doco-cd    doco-cd
                                  Host A     Host B     Host C
                                     |          |          |
                                     +------ SOPS decrypts ------+
                                                |
                                                v
                                       docker compose up

Git is the only source of the desired state. Renovate does not change running containers; it only changes files in the repository. doco-cd, in turn, does not invent updates on its own and deploys only what exists on master.

That distinction matters. A new container image does not become production simply because it appeared in a registry. It becomes production because a concrete change in the deployment repository was merged.

Repository structure

The repository is organised first by host and then by stack:

.
├── .doco-cd.arkham.yaml
├── .doco-cd.innsmouth.yaml
├── .doco-cd.windmill.yaml
├── .sops.yaml
├── renovate.json5
├── .github/
│   └── workflows/
│       └── renovate-automerge-trigger.yml
├── bootstrap/
│   ├── arkham/docker-compose.yaml
│   ├── innsmouth/docker-compose.yaml
│   └── windmill/docker-compose.yaml
├── arkham/
│   ├── caddy/
│   │   ├── docker-compose.yaml
│   │   └── Caddyfile
│   ├── matomo/
│   │   ├── docker-compose.yaml
│   │   └── stack.env
│   └── ...
├── innsmouth/
│   └── ...
├── windmill/
│   └── ...
└── disabled/
    └── ...

Each directory below a host corresponds to exactly one Compose project. That lets doco-cd discover stacks automatically without maintaining every one of them in another central list.

The repository contains:

  • Compose files
  • application configuration such as Caddyfiles
  • encrypted environment files and private configuration values
  • public material such as certificate chains or public keys

It does not contain:

  • persistent application data
  • databases
  • the private age key
  • doco-cd’s GitHub access token
  • the webhook secret
  • local registry credentials

Persistent data lives consistently under /opt/docker/volumes/<stack>/... on the hosts. A Compose file therefore contains something like:

services:
  database:
    image: mariadb:11.4
    restart: unless-stopped
    volumes:
      - /opt/docker/volumes/example/database:/var/lib/mysql
    env_file:
      - stack.env

The repository can restore containers and their configuration, but not their data. GitOps does not replace backups. That is not a limitation of doco-cd; it is a boundary I draw deliberately.

One doco-cd instance per Docker host

Every Docker host runs its own doco-cd instance. I chose this over one central service with access to several exposed remote Docker APIs. Each instance sees only its local Docker socket and manages only its own part of the repository.

The host configuration is small. For arkham, it essentially looks like this:

name: arkham
working_dir: arkham
reference: master
webhook_filter: '^refs/heads/master$'

auto_discovery:
  enabled: true
  depth: 1
  delete: false

Several important decisions sit behind those few lines.

Only master is deployed

reference: master determines which revision is checked out. The webhook_filter additionally ensures that only pushes to master trigger a run.

This matters particularly in combination with Renovate. Renovate first pushes changes to its own branches. Without a fixed reference and filter, a webhook could in the worst case deploy a Renovate branch before the pull request had even been reviewed or merged.

The rule is therefore explicit:

Branch or pull request = proposal
master                 = approved desired state

Stacks are discovered automatically

auto_discovery.depth: 1 means that doco-cd scans exactly one level below arkham/. Every directory containing a Compose file becomes a deployment of its own.

Adding a stack therefore does not require another central configuration entry. Creating a new directory is enough:

arkham/new-service/docker-compose.yaml

The next run discovers the stack automatically.

Deletion is deliberately not automatic

delete: false is conservative, but it is the right default for my homelab. If a directory briefly disappears because of a bad commit, merge conflict or restructuring, doco-cd should not immediately remove containers.

I move retired stacks under disabled/. Because of the discovery depth, they are no longer deployed there. Actually stopping and removing them remains a deliberate manual action. Data under /opt/docker/volumes remains untouched as well.

That also makes rollback straightforward: move the directory back, commit and deploy again. The application data is still present.

Bootstrap stays outside the automation

Something has to start doco-cd for the first time. That Compose file lives under bootstrap/<host> and deliberately not inside the automatically discovered host directory. doco-cd should not redeploy itself and cut off the branch it is sitting on.

A shortened bootstrap configuration looks like this:

services:
  doco-cd:
    image: ghcr.io/kimdre/doco-cd:0.101.1
    container_name: doco-cd
    restart: unless-stopped
    cap_drop:
      - ALL

    environment:
      TZ: Europe/Zurich
      LOG_LEVEL: info
      GIT_ACCESS_TOKEN_FILE: /run/secrets/git_token
      SOPS_AGE_KEY_FILE: /run/secrets/age_key
      WEBHOOK_SECRET_FILE: /run/secrets/webhook_secret
      POLL_CONFIG: |
        - url: https://github.com/example/docker-deployment.git
          reference: master
          interval: 3600
          target: arkham

    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - doco-cd-data:/data

    secrets:
      - git_token
      - age_key
      - webhook_secret

secrets:
  git_token:
    file: /opt/docker/secrets/git_token
  age_key:
    file: /opt/docker/secrets/age.key
  webhook_secret:
    file: /opt/docker/secrets/webhook_secret

volumes:
  doco-cd-data:

The version is pinned. For a component that deploys every other stack, I do not want a restart to pull in a new latest version unnoticed.

The three bootstrap secrets have to exist locally on the host:

  • a GitHub token restricted to this repository with read access
  • the private age key used for decryption
  • the shared HMAC secret for GitHub webhooks

These files cannot sensibly be loaded with SOPS from the same repository. Without the Git token, doco-cd cannot clone the repository; without the age key, it cannot decrypt the secrets stored there. That small manual bootstrap is the unavoidable exception in an otherwise automated flow.

The Docker socket is the real trust boundary

Mounting /var/run/docker.sock gives doco-cd effectively administrative control over the host. cap_drop: ALL is still useful, but it does not change the fundamental power conveyed by the Docker socket.

Security therefore has to be enforced elsewhere:

  • The Git token has read access to exactly one repository.
  • Webhooks are verified with an HMAC secret.
  • Only master may be deployed.
  • The main branch should be protected against accidental direct changes.
  • Changes to the deployment repository are security-relevant production changes.

A compromised deployment repository is not merely a documentation problem. It is a compromised control channel for every managed Docker host.

Webhooks for immediate deployment, polling as a fallback

doco-cd can poll the repository or be started through a webhook. I use both.

A push to master triggers one GitHub webhook per host. The reverse proxy routes the individual paths to the corresponding doco-cd instance:

POST /v1/webhook/arkham    -> doco-cd on arkham
POST /v1/webhook/innsmouth -> doco-cd on innsmouth
POST /v1/webhook/windmill  -> doco-cd on windmill

All three webhooks fire on a push. Each instance, however, reconciles only its own host section. If nothing changed there, the run has no effect.

Each instance also polls the repository at a substantially longer interval. That is not a second primary deployment mechanism, but a fallback for a missed webhook or temporary network failure.

The practical benefit of combining both is that deployment normally starts a few seconds after the merge, while the desired state does not permanently depend on successful delivery of one webhook.

Private container registries add one easily overlooked detail: a docker login on the host is not automatically visible inside a container. doco-cd needs a Docker configuration visible to its container, for example through a read-only mounted DOCKER_CONFIG. Otherwise one failed image pull can abort deployment of the entire affected stack.

SOPS: secrets belong in the repository, but not in plain text

The previous alternative was to keep secrets only on the hosts. That sounds secure at first, but has a substantial drawback: the Compose file is versioned while an essential part of its configuration is not. After reinstalling a machine, somebody has to know which file with which contents belongs in which location.

With SOPS, those files live in the repository too. Encryption uses an age recipient. The public age key is stored in .sops.yaml; the private key exists only on the hosts and in a separate offline backup.

My configuration encrypts environment files selectively:

creation_rules:
  - path_regex: '\.env$'
    encrypted_regex: '(?i)(PASSWORD|PASS|TOKEN|SECRET|APIKEY|HASH|DATABASE_URL)'
    age: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

  - path_regex: '.*'
    age: age1xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The simplified result looks like this:

TZ=Europe/Zurich
APP_PORT=8080
DATABASE_USER=example
DATABASE_PASSWORD=ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]
API_TOKEN=ENC[AES256_GCM,data:...,iv:...,tag:...,type:str]

Timezone, ports, hostnames and feature flags remain readable. Only values whose key name indicates a secret are encrypted. Pull requests therefore remain understandable. A change from APP_PORT=8080 to APP_PORT=8081 is still immediately visible in the diff.

Structured YAML configuration gets its own rules. There I encrypt only concrete leaf keys such as client_secret, password, token or Authorization. Private keys, PKCS#12 files and other entirely confidential files are encrypted completely.

Rule order matters. Specific rules have to come before the general catch-all.

Editing secrets

Locally, SOPS points at the private age key:

export SOPS_AGE_KEY_FILE="$HOME/.config/sops/age/keys.txt"

An encrypted file can then be edited directly:

sops arkham/matomo/stack.env

SOPS opens the decrypted content in the editor and encrypts it again when saving. For read-only inspection:

sops decrypt arkham/matomo/stack.env

A new file is encrypted according to the matching rule in .sops.yaml:

sops --encrypt --in-place arkham/example/stack.env

After encrypting a file for the first time, it is always worth checking git diff. A typo in a variable name can cause a secret value to fall outside encrypted_regex. DATABASE_PASSWORD is encrypted by my rule, while DATABASE_CREDENTIAL, for example, is not.

The rules in .sops.yaml do not encrypt files automatically just because they live in the repository. They define what SOPS does when it is invoked for a file.

env_file instead of Compose interpolation

I inject secrets into Compose through env_file:

services:
  app:
    image: example/app:1.2.3
    env_file:
      - stack.env

I avoid constructs like this:

services:
  app:
    environment:
      DATABASE_PASSWORD: ${DATABASE_PASSWORD}

That prevents the secret value from passing through ${...} interpolation in the Compose file. This is particularly relevant for special characters such as a literal $ inside a password. The decrypted file is still confidential, of course: docker compose config can also print environment values and therefore does not belong unfiltered in logs.

During deployment, doco-cd clones the repository, decrypts the SOPS files in its checkout and then invokes Docker Compose. There is no second divergent copy of stack.env to maintain on the host.

Renovate as the update engine

Once image tags are stored in Git, Renovate can evaluate them. This is the second half of the GitOps loop: Renovate discovers a new state, opens a pull request and leaves the decision to repository policy.

The foundation of my configuration is small:

{
  extends: [
    'config:recommended',
    ':dependencyDashboard',
    ':timezone(Europe/Zurich)',
  ],

  automergeSchedule: ['* 1-5 * * 0'],
  platformAutomerge: false,

  packageRules: [
    {
      matchUpdateTypes: ['patch', 'pin'],
      automerge: true,
    },
    {
      matchUpdateTypes: ['major'],
      dependencyDashboardApproval: true,
    },
    {
      matchDatasources: ['docker'],
      matchPackageNames: ['postgres', 'docker.io/library/postgres'],
      matchUpdateTypes: ['major'],
      enabled: false,
    },
  ],
}

Patch updates and pure digest pins may be merged automatically, but only during a Sunday window between 01:00 and 06:00. Minor updates stay visible and are reviewed manually. Major updates first need approval in the Dependency Dashboard.

PostgreSQL major updates are disabled completely. Moving PostgreSQL 16 to 17 is not a normal image update but a database migration. Automatically changing the tag would result at best in a container that refuses to start, and at worst in an unclear data state.

The rules therefore model the risk of the change, not merely its version number.

Not every Docker tag is SemVer

Renovate encounters many different tagging schemes for Docker images. Some use conventional SemVer, others add build numbers, Git hashes or architecture suffixes. I define dedicated versioning rules for such images.

An example for an image using a date or monotonically increasing build number:

{
  matchPackageNames: ['jellyfin/jellyfin'],
  versioning: 'regex:^(?<major>\\d{10})-amd64$',
}

Rolling tags such as latest or main are another special case. The tag itself does not change even when the image behind it does. For those, I enable digest pinning:

{
  matchDatasources: ['docker'],
  matchPackageNames: ['ghcr.io/windmill-labs/**'],
  pinDigests: true,
  groupName: 'windmill',
}

This turns:

image: ghcr.io/example/service:latest

into:

image: ghcr.io/example/service:latest@sha256:0123456789abcdef...

The deployment is now reproducible. latest remains only the human-readable label; the immutable digest is decisive. When the digest behind the tag changes, Renovate can open an ordinary pull request.

For private registries, Renovate needs its own read access. The access token is not stored in plain text inside renovate.json5; it uses Renovate’s repository-specific encryption mechanism in a hostRules entry.

Groups need their own risk class

Grouping images from the same vendor into one pull request reduces PR volume. I group LinuxServer images, for example, but deliberately keep patch and minor updates in separate groups.

The reason is Renovate’s automerge logic: a grouped PR is merged automatically only when every contained update is eligible for automerge. One minor update can otherwise block several low-risk patch updates in the same branch.

The rules are therefore separated:

{
  matchDatasources: ['docker'],
  matchPackageNames: ['lscr.io/linuxserver/**'],
  matchUpdateTypes: ['patch', 'pin'],
  groupName: 'linuxserver patch',
},
{
  matchDatasources: ['docker'],
  matchPackageNames: ['lscr.io/linuxserver/**'],
  matchUpdateTypes: ['minor', 'major'],
  groupName: 'linuxserver minor',
},

That preserves the convenience of grouping without allowing one larger update to delay smaller security and bugfix releases.

I deliberately keep directly exposed core services such as the reverse proxy or identity provider out of large grouped PRs. I want to read their release notes and possible impact individually.

The automerge window also needs a Renovate run

One small trap only became obvious in operation: automergeSchedule does not mean Renovate will automatically start at that time. It only means a Renovate job that happens to be running during that window may merge.

The scheduler of the hosted Renovate app missed my originally narrow Sunday window for weeks. The pull requests were eligible for automerge, but were never merged.

The repository therefore contains a small GitHub Actions workflow. It runs on Sunday inside the automerge window and activates the manual job checkbox in the Renovate Dependency Dashboard. That requests a Renovate run. The window is deliberately several hours wide because neither GitHub cron nor the hosted app’s queue runs to the second.

This is not a fundamental Renovate problem, but it is a useful example of how schedules always have two sides:

When may Renovate merge?
When does Renovate actually run?

Both have to line up.

Own images do not need to wait for Renovate

For images from third-party projects, Renovate is the right mechanism. For images built by my own projects, a more direct path is often better.

My blog repository builds a new image itself after a change and pushes it to the registry. The CI job then knows the exact manifest digest. Instead of waiting for the next Renovate run, CI writes that digest directly into the Compose file of the deployment repository and commits the change:

Push in application repository
  -> Build image
  -> Push image
  -> Write exact digest to deployment repository
  -> Push to master
  -> doco-cd deploys via webhook

Renovate remains configured for that image as a fallback. The normal deployment path, however, is the project’s own CI. That is faster and ties a specific commit directly to a specific image digest.

GitOps does not mean every change has to come from Renovate. What matters is that the direct CI path also produces a traceable change to the desired state in Git.

Migrating manual Compose directories to doco-cd

Migrating an existing host should not begin by enabling auto-discovery for every stack. doco-cd would immediately attempt to deploy everything it finds from the new checkout even though data paths or configuration files may not have been migrated yet.

I therefore started with explicit pilot stacks:

POLL_CONFIG: |
  - url: https://github.com/example/docker-deployment.git
    reference: master
    interval: 180
    deployments:
      - name: homarr
        working_dir: arkham/homarr
      - name: sillytavern
        working_dir: arkham/sillytavern

Each existing stack was then migrated separately. Order matters:

docker compose -f /opt/docker/compose/example/docker-compose.yaml down
mkdir -p /opt/docker/volumes
mv /opt/docker/example /opt/docker/volumes/example
# danach doco-cd den Stack deployen lassen

Stop first, move data second, deploy third. If the new stack starts too early, Docker creates missing bind-mount directories as empty directories. At that point it is no longer immediately obvious which path contains the real data.

Only after all stacks on a host had been migrated did I switch from the explicit list to target: arkham and therefore auto-discovery.

What remains after an update

An automated deployment updates containers, but it does not necessarily clean up old images. I noticed this after replacing my previous update service: after every tag update, the previous tagged image remained on the host.

A normal docker image prune removes only unused dangling images. Old images that still have a tag remain. I therefore run a small prune stack on every host:

docker image prune -af \
  --filter until=24h \
  --filter 'label!=prune=never'

docker builder prune -af --filter until=168h

The time window protects freshly built images. The prune=never label protects local images that cannot be pulled from a registry and would have to be rebuilt. The BuildKit store is pruned separately because image pruning does not cover it.

prune -a in particular should not be copied blindly. Anyone building local images or deliberately retaining unused images for fast rollback needs appropriate exceptions.

How a normal update runs

A typical patch update now passes through these steps:

  1. Renovate discovers a new image tag or digest.
  2. Renovate opens a pull request changing the Compose file.
  3. A patch or pin update is merged automatically during the Sunday window. Larger updates remain open for manual review.
  4. The push to master triggers the host webhooks.
  5. The corresponding doco-cd instance clones the current state of master.
  6. doco-cd decrypts the SOPS files using the local age key.
  7. Docker Compose pulls the new image and recreates the affected containers.
  8. Persistent data remains in bind mounts on the host.

The entire path is visible in Git: which version was proposed, which pull request introduced it and which commit was deployed.

A rollback starts with git revert. The resulting push runs the same deployment path in reverse. This works well for container configuration and image versions, but not automatically for database migrations. If a new application version changed the data format, the rollback needs a corresponding data plan as well.

Likewise, a Compose recreate is not a rolling deployment. Depending on the service, replacing the container causes a brief interruption. That is acceptable in my homelab. Anyone requiring zero-downtime deployment or automatic distribution across multiple nodes eventually reaches a point where a real orchestrator becomes more appropriate.

Why this model works for me

The solution is substantially smaller than a Kubernetes installation but provides exactly the properties I was missing from manually maintained Compose directories:

  • The running state can be derived from Git.
  • Changes are traceable as commits and pull requests.
  • Secrets are versioned without living in plain text in the repository.
  • Updates are discovered automatically but handled differently according to risk.
  • A merge is also the deliberate approval for deployment.
  • Every host manages only its own section.
  • Webhooks provide fast reaction while polling prevents permanent drift.

The most important part is not one of the three tools in isolation. It is the clean division of responsibility:

doco-cd   deploys the approved desired state.
Renovate  proposes new versions.
SOPS      makes secrets versionable.
Git       connects everything and preserves the history.

That has turned a collection of Docker Compose files into a manageable GitOps system. It is not maximally abstract and not designed for every conceivable infrastructure case, but for several fixed Docker hosts it is easy to understand, maintain and, when something breaks, surprisingly simple to repair.