A minimal, fully declarative, git-driven, secure fully self-contained GitOps-style deployer for docker compose stacks, with encrypted secrets via SOPS and notifications via Apprise. Easily configured via environment variables and triggered by webhooks from Forgejo, Gitea, Gogs, GitHub or GitLab.
Docker Operator is a Docker application that watches a git repository and keeps your docker compose stacks in sync with it. You push a change, the stacks that changed are redeployed. That is all it does.
The design is simple. Git is the source of truth. A stack is a folder with a compose.yaml, so there is nothing to register, and the only things the operator remembers are the hash of what it deployed last and how many times a failing change was retried, in a small JSON file. No web UI, no database, no agents, just one container.
- It can deploy straight from the bare repository on your Forgejo or Gitea host, mounted read-only. No access token is needed, it can't change your repository, and it keeps working when the git server is down.
- If git is unreachable, the last known-good checkout is kept and it tries again later.
- Secrets stay encrypted in git (SOPS + AGE). They are decrypted only at deploy time, into a
0600file, and never logged. A token or password inGIT_REPO_URLshows as***in logs and notifications. - A stack that owns a Docker network is deployed before the stacks that use it.
- A stack is validated before it is deployed, so a broken
compose.yamlnever touches what is running. If the deploy itself fails it is retried, and the deploy folder keeps the last version that worked. - Every stack is written as a plain
compose.yamland a single.env(config and secrets merged) in a folder you can open. Usedocker compose up,downorlogsthere as usual, alongside the operator. - Every stack gets its own message, on any service Apprise supports, with the status of each container.
The first run deploys every stack in the repository. After that, only the ones that changed.
This takes four steps. You put your stacks in a git repository, configure and start Docker Operator, and tell your git server to call it on every push. When you are done, each push deploys the stacks that changed.
You need Docker with Compose and a git server: Forgejo, Gitea, Gogs, GitHub or GitLab. If Forgejo or Gitea runs on the same machine, Docker Operator reads the repository straight from its disk. Otherwise, set GIT_REPO_URL to a git URL with a read-only token instead of using the repository volume.
Encrypted secrets are optional. You can start without them and add them later, see the section Secrets.
Docker Operator deploys whatever it finds in your stacks repository, so the first thing to do is to lay it out the way it expects. Create a compose/ folder at the root, and inside it one folder per stack. The folder name is the name of the stack, and each one needs at least a compose.yaml. Everything else is optional, see Stack Files. When it looks like the example below, commit it and push it.
homelab-docker/
└── compose/
├── traefik/
│ ├── compose.yaml
│ ├── .env.config # optional, plain variables
│ ├── .env.secrets.encrypted # optional, see Secrets
│ └── .env.secrets.example # optional, lists the secret names, never deployed
└── grafana/
└── ...
git clone https://github.com/ddrimus/docker-operator.git
cd docker-operator
cp example.env .envIn .env, this is the minimum you need to change:
# a random value, for example the output of: openssl rand -hex 32
WEBHOOK_SECRET=
# leave it empty if you don't use encrypted secrets
SOPS_AGE_KEY_FILE=Everything else in example.env already has a working default, including the required GIT_REPO_URL, which matches the repository volume in compose.yaml. Two more you will probably want: GIT_BRANCH if your branch is not main, and NOTIFY_URLS to get notifications, see Notifications. The full list is in Configuration.
In compose.yaml, under the docker-operator service, publish the webhook port by adding:
ports:
- "8080:8080"Then replace the /path/to/forgejo/data/git/repositories/owner/repo.git volume with the path of your stacks repository on the Forgejo host.
Using encrypted secrets? Put your AGE key in data/config/sops_age.key and leave SOPS_AGE_KEY_FILE as it is, see Secrets.
docker compose up -d
docker compose logs -f docker-operator # Ctrl+C to stop followingThe logs should show listening on 0.0.0.0:8080/webhook, then the first deploy of your stacks.
In your stacks repository, add a webhook, usually under Settings → Webhooks, and fill in:
- URL:
http://YOUR_SERVER_IP:8080/webhook - Secret: the
WEBHOOK_SECRETfrom your.env - Trigger on: push events, with content type
application/json
Forgejo, Gitea, Gogs, GitHub and GitLab are all recognized on their own. Send a test delivery, any 2xx answer means it works and 401 means the secret doesn't match. From now on, every push deploys your stacks. If your git server can't reach Docker Operator, skip the webhook, it also checks for changes every POLL_INTERVAL_SECONDS.
Once it is running, you work on your stacks in git and Docker Operator does the rest. This is what a stack is made of, where its files end up, and how to keep its secrets encrypted.
Every directory under COMPOSE_SUBDIR that contains a compose.yaml is a stack, named after its folder. Everything below must be committed to git:
| File | Purpose |
|---|---|
compose.yaml |
The stack itself. |
.env.config |
Plain, non-sensitive environment variables. |
.env.secrets.encrypted |
Secrets encrypted with SOPS + AGE, decrypted at deploy time. |
.depends_on |
One stack name per line (# comments allowed), deployed before this stack. |
.paused |
An empty file which keeps the stack from being deployed until it's removed. |
Every deploy writes the stack to /deploy/<stack> in the container, which is data/data/deploy/<stack> on the host with the default compose.yaml. It holds the compose.yaml and one .env with the config and the decrypted secrets merged. Set DEPLOY_UID and DEPLOY_GID to your user to open it without root, then work as usual:
cd data/data/deploy/traefik
docker compose logs -f
docker compose downThe files stay as they are until that stack changes in git and is deployed again. To keep the operator away from a stack meanwhile, commit a .paused file in its folder.
Only the compose.yaml and the .env are deployed, and docker compose runs inside the operator container. A relative path like ./config doesn't point to your host, so use absolute host paths or named volumes for anything a stack mounts.
Secrets are stored in git encrypted. Each stack's .env is built from .env.config plus the decrypted .env.secrets.encrypted, values are never logged. You need AGE and SOPS on the machine where you edit your stacks.
# 1. create your key, keep it out of git and back it up
age-keygen -o sops_age.key && chmod 600 sops_age.key
# 2. write the secrets, then encrypt them in place with your public key (age1...)
echo 'DB_PASSWORD=your-password' > compose/traefik/.env.secrets.encrypted
sops --encrypt --age age1yourpublickey... --in-place \
--input-type dotenv --output-type dotenv compose/traefik/.env.secrets.encrypted
# 3. edit them later
SOPS_AGE_KEY_FILE=sops_age.key sops edit \
--input-type dotenv --output-type dotenv compose/traefik/.env.secrets.encryptedBefore you commit, open the file and check that the values look like ENC[...]. If you can read them, they are not encrypted. The key file never goes in git, it stays on the server in data/config/sops_age.key, see Configure.
Notifications are sent with Apprise, so they work with almost any service. Set NOTIFY_URLS to one or more Apprise URLs, separated by commas. The most common ones:
| Service | NOTIFY_URLS |
|---|---|
| Telegram | tgram://BOT_TOKEN/CHAT_ID |
| Discord | discord://WEBHOOK_ID/WEBHOOK_TOKEN?format=markdown&avatar=no |
| Slack | slack://TOKEN_A/TOKEN_B/TOKEN_C |
Every other service is in the Apprise wiki. Each stack gets its own message when it is deployed, retried, fails or is removed, with the status of each container.
This is the full list of the environment variables you can set in your .env, the same ones as in example.env. Please read everything carefully before asking for help, as most problems are simple configuration issues.
| Variable | Default | Description |
|---|---|---|
GIT_REPO_URL |
required | Git URL or bind-mounted path of the repository holding your stacks. |
WEBHOOK_SECRET |
required | Secret used to verify webhook signatures. |
GIT_BRANCH |
main |
Branch to deploy from. Pushes to other branches are ignored. |
COMPOSE_SUBDIR |
compose |
Directory in the repository which contains the stacks. |
WEBHOOK_PATH |
/webhook |
Path the webhook is served on. |
SOPS_AGE_KEY_FILE |
AGE key used to decrypt secrets. Required if any stack has .env.secrets.encrypted. |
|
PULL_IMAGES |
true |
Pull images before deploying. |
PRUNE_REMOVED_STACKS |
false |
Tear down stacks removed from git. |
POLL_INTERVAL_SECONDS |
300 |
How often to reconcile without a webhook. 0 disables polling. |
DEPLOY_TIMEOUT_SECONDS |
300 |
Time a single deploy has before it's considered failed. |
DEPLOY_MAX_RETRIES |
3 |
Attempts at a failing change before giving up. |
DEPLOY_RETRY_DELAY_SECONDS |
60 |
Delay between retries. |
DEPLOY_PRIORITY |
Comma-separated stack names to deploy first, where dependencies allow. | |
DEPLOY_UID / DEPLOY_GID |
Owner of /deploy, so you can docker compose in it as yourself. |
|
NOTIFY_URLS |
Apprise URLs for notifications, comma-separated, see Notifications. | |
LOG_LEVEL |
INFO |
Log verbosity. |
TZ |
UTC |
Timezone used for logs and notifications. |
Run these inside the container with docker compose exec docker-operator python -m docker_operator <flag>:
--onceruns a single reconcile pass and exits.--force STACKredeploysSTACKeven if unchanged (repeatable,allfor every stack), requires--once. For example,docker compose exec docker-operator python -m docker_operator --once --force traefik.--validatevalidates every stack's compose config without deploying, handy for CI.
A change that keeps failing is retried DEPLOY_MAX_RETRIES times, then waits for the next change in git, even across restarts. Once you fixed the cause, retry it right away with --force.
If you have an improvement, extra information, or you notice something wrong with Docker Operator, feedback is welcome. Feel free to open a pull request with your suggestion or the details.
To build the image from source, use the development compose file, which builds the local Dockerfile instead of pulling the published image:
docker compose -f compose.dev.yaml up -d --buildIf something doesn't work as you expect, please open an issue with everything relevant: what you did, what you expected, and the logs from docker compose logs docker-operator (set LOG_LEVEL=DEBUG for more). Remove any secret or token before you paste them. Your suggestions and feedback are always welcome and appreciated!