GitOps is almost always presented with Kubernetes in the background: ArgoCD, Flux, manifests by the hundreds. As a result, many teams deploying on a plain VPS with Docker Compose assume the model does not apply to them. They keep deploying over SSH: git pull, docker compose up -d, crossing their fingers that nobody skips a step.
That is a shame, because the core of GitOps has nothing to do with Kubernetes. And for some time now, one tool has been filling exactly this gap: doco-cd, a continuous deployment engine for Docker Compose. Combined with SOPS and age to encrypt secrets directly in the repo, you get GitOps without Kubernetes: a complete pipeline where deploying boils down to git push. This article explains the principle, then walks through the setup on a Django project with several compose files.
GitOps, the principle without the jargon
GitOps comes down to three ideas:
- Git is the source of truth. The desired state of the infrastructure (which services, which images, which configuration) is described in a repo. Not in the ops person’s head, not in a Notion runbook.
- Deployment follows a pull model. An agent runs on the target server and watches the repo. When the declared state changes, the agent applies the difference. Nobody pushes commands to the server.
- Every change goes through a commit. Deploying means merging. Rolling back means
git revert. The deployment history is the Git history, with author, date and diff.
The contrast with classic push deployment (a CI pipeline that connects over SSH and runs commands) is real. In push mode, the CI holds SSH credentials to production, and the server state silently drifts as soon as someone runs a command by hand. In pull mode, the server opens itself to no one: it fetches the information itself, and it reapplies the declared state on every change.
Nothing in these three ideas requires Kubernetes. All you need is an agent able to read a repo and reconcile the state of a Docker host. That is exactly what doco-cd does.
doco-cd: the ArgoCD model applied to Docker Compose
doco-cd (Docker Compose Continuous Deployment) is a GitOps agent written in Go, shipped as a minimal distroless image with a tiny memory and CPU footprint. It describes itself as a simple Portainer or ArgoCD alternative for Docker. How it works:
- It watches one or more Git repos, either via webhook (the Git provider notifies it on every push) or via polling (it checks the repo at a regular interval).
- On every detected change, it clones the repo, reads a
.doco-cd.yamlconfiguration file at the root, and runs the equivalent ofdocker compose up -dwith the declared compose files. - It talks directly to the host’s Docker socket. No extra daemon, no database.
Two things set it apart from homemade scripts:
SOPS decryption is native. At deployment time, doco-cd inspects the project files (compose files, .env files, configs, secrets, mounted volumes) and decrypts on the fly any file encrypted with SOPS. Detection is content-based, no naming convention is required.
The deployment configuration lives in the application repo. The .doco-cd.yaml file versioned next to the code describes what to deploy and how. The agent on the server carries almost no configuration of its own.
Secrets, the real problem of GitOps
If Git is the source of truth, secrets have to live there too. Otherwise you fall back into the original problem: a .env file dropped by hand on the server, outside any versioning, that nobody can reconstruct the day the machine dies.
But committing POSTGRES_PASSWORD=hunter2 in plain text is obviously out of the question. The ecosystem’s classic answer is SOPS (Secrets OPerationS), a tool that encrypts the values of a structured file (YAML, JSON, dotenv, INI) while leaving the keys readable. The encrypted file stays diffable: a PR shows that POSTGRES_PASSWORD changed, without ever revealing its value.
SOPS delegates encryption to a backend. Historically PGP, but today the recommendation is age: a modern encryption tool, with no key servers and no web of trust, short keys and a minimal format. An age key pair fits on two lines of text.
The complete flow becomes:
- Developers encrypt secrets with the age public key. It can be committed, anyone can encrypt.
- Only the server holds the private key. doco-cd uses it to decrypt at deployment time.
- The repo contains everything, but reveals nothing.
The example project: Django, Celery and three compose files
Let’s take a realistic Django project: an API with its PostgreSQL database, a Celery worker with Redis, and a separate compose file for the worker part so it can evolve independently. This is the typical topology as soon as you handle asynchronous tasks, for instance the relay of the Transactional Outbox pattern. The layout:
django-shop/
├── .doco-cd.yaml # doco-cd deployment config
├── .sops.yaml # SOPS encryption rules
├── src/ # the Django code
│ ├── manage.py
│ └── config/
└── deploy/
├── compose.yaml # web + postgres
├── compose.worker.yaml # celery + redis
└── .env # secrets, encrypted with SOPS
The deploy/compose.yaml file:
services:
web:
image: ghcr.io/myorg/django-shop:${IMAGE_TAG:-latest}
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000
env_file: .env
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:17-alpine
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"]
interval: 5s
retries: 10
restart: unless-stopped
volumes:
pgdata:
And deploy/compose.worker.yaml, merged with the first one at deployment time:
services:
worker:
image: ghcr.io/myorg/django-shop:${IMAGE_TAG:-latest}
command: celery -A config worker --loglevel info
env_file: .env
depends_on:
- redis
restart: unless-stopped
redis:
image: redis:7-alpine
restart: unless-stopped
Note the ${IMAGE_TAG:-latest}: the application image is built by the CI (outside the scope of this article) and pushed to a registry. Deploying a new version means changing IMAGE_TAG in the .env and pushing the commit. The repo describes the desired state, doco-cd applies it.
Step 1: generate the age key pair
On your machine:
age-keygen -o age.key
# Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
The age.key file contains the private key (a line starting with AGE-SECRET-KEY-). It never joins the repo: it goes to the server, and to a password manager for backup. The public key shown in the comment, on the other hand, can circulate freely.
Step 2: configure SOPS and encrypt the .env
The .sops.yaml file at the repo root declares which encryption rules apply to which paths:
creation_rules:
- path_regex: deploy/.*\.env$
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
Then write the deploy/.env in plain text, locally:
DJANGO_SECRET_KEY=django-insecure-change-me
DJANGO_ALLOWED_HOSTS=shop.example.com
POSTGRES_DB=shop
POSTGRES_USER=shop
POSTGRES_PASSWORD=a-real-password
CELERY_BROKER_URL=redis://redis:6379/0
IMAGE_TAG=1.4.2
And encrypt it in place:
sops encrypt --in-place deploy/.env
The file remains structurally a valid dotenv, but every value is replaced by an ENC[AES256_GCM,...] block, and SOPS appends its metadata (including the age public key used):
POSTGRES_PASSWORD=ENC[AES256_GCM,data:8Zw1p9X...,iv:...,tag:...,type:str]
This encrypted file is what gets committed. To modify it later, sops edit deploy/.env opens it decrypted in your editor and re-encrypts it on save. The Git diff shows which keys changed, never the values.
Step 3: declare the deployment in .doco-cd.yaml
At the repo root:
name: django-shop
working_dir: deploy
compose_files:
- compose.yaml
- compose.worker.yaml
Three lines of useful configuration: the compose project name, the working directory, and the list of compose files to merge. It is the equivalent of the -f compose.yaml -f compose.worker.yaml you would type by hand. A third compose file (monitoring, exporters, cron jobs) is one more line.
Step 4: install doco-cd on the server
The only component installed manually on the server, once and for all. A dedicated compose file:
services:
doco-cd:
image: ghcr.io/kimdre/doco-cd:latest
restart: unless-stopped
environment:
TZ: Europe/Paris
GIT_ACCESS_TOKEN: ${GIT_ACCESS_TOKEN} # read-only token for the repo
SOPS_AGE_KEY_FILE: /run/secrets/age_key
POLL_CONFIG: |
- url: https://github.com/myorg/django-shop.git
reference: main
interval: 180
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- data:/data
secrets:
- age_key
healthcheck:
test: ["CMD", "/doco-cd", "healthcheck"]
interval: 30s
secrets:
age_key:
file: ./age.key
volumes:
data:
Two choices deserve an explanation.
The age key goes through a compose secret, mounted into the container and referenced via SOPS_AGE_KEY_FILE, rather than through the SOPS_AGE_KEY variable in plain text in the environment. A docker inspect on the container will not expose the private key.
Polling rather than webhook, here every 180 seconds on the main branch. doco-cd also supports webhooks (WEBHOOK_SECRET variable, an HTTP endpoint to expose to the Git provider), which gives instant deployments. But a webhook requires exposing doco-cd to the internet behind a reverse proxy. For a server that has no reason to receive inbound traffic from the Git provider, polling is simpler and largely sufficient: a three minute latency on a deployment is rarely a problem.
Start it:
docker compose up -d
From that point on, every push to main that modifies the project triggers a redeployment. doco-cd clones the repo, detects that deploy/.env is encrypted (the content contains the SOPS markers), decrypts it with the age key, interpolates the variables, merges the two compose files and applies the state.
Django migrations
A docker compose up does not run migrations. The simplest approach in this model is to run them at container startup, via an entrypoint:
#!/bin/sh
set -e
python manage.py migrate --noinput
exec "$@"
This is the usual trade-off of single-host deployments: acceptable as long as only one web container starts at a time. If you scale the web service to several replicas, move the migrations into a dedicated one-shot service to avoid concurrent runs.
What it changes day to day
The switch looks modest. It is not.
Deploying becomes a non-event. Change IMAGE_TAG=1.4.3, open a PR, merge. No deployment window, no “who has their hands on the server?”.
Rollback is a revert. The previous state is a Git commit. git revert, and three minutes later the server runs the old version. No specific procedure to document.
Auditing is free. Who changed the Redis config in production, when, and why? git log deploy/. The question “what is running in prod?” has an exact answer: whatever main describes.
The server becomes replaceable. Provisioning a new server boils down to installing Docker, dropping the age key and starting doco-cd. Everything else flows from the repo.
The limits to know about
doco-cd manages one Docker host (or a Swarm cluster), not a fleet. No multi-node scheduling, no autoscaling, no native progressive delivery: if you need those, it is the sign that Kubernetes and ArgoCD are becoming relevant. The whole point of doco-cd is precisely not to pay that cost until you need it.
The private age key on the server is the sensitive spot of the setup. Whoever holds it can decrypt every secret in the repo, including in the Git history. Two practical consequences: restrict access to the server the way you would restrict access to a vault, and accept that rotating the key does not erase the past (a compromised secret gets changed, it does not get retroactively “un-decrypted”).
Finally, SOPS encrypts files, not access. There is no per-secret access control, no consultation log, no dynamic credential generation. For a three-person team on a VPS, that is exactly the right level of complexity. For finer needs, doco-cd can also query external secret managers (OpenBao, AWS Secrets Manager, Bitwarden, 1Password), which offers an evolution path without changing deployment tools.
Conclusion
GitOps is not a technology, it is a contract: Git describes the desired state, an agent applies it, every change goes through a commit. Kubernetes popularized the model, but a VPS with Docker Compose is entitled to it too. doco-cd provides the agent, SOPS and age settle the secrets question, and the result fits in a few versioned files: two compose files, an encrypted .env, a .doco-cd.yaml, a .sops.yaml. The day the project outgrows this setup, the reflexes acquired (declared state, pull model, encrypted secrets in the repo) transfer as-is to Flux or ArgoCD. In the meantime, deploying becomes what it should always have been: a git push.
