What you'll learn:
- What SwarmCD is and how it compares to ArgoCD
- How the SwarmCD configuration file works
- Why Docker Swarm configs and secrets are immutable — and what that means for updates
- How SOPS + age encryption keeps secrets in Git without exposing them
- The complete GitOps loop from
git pushto running container
The Loop
git push
↓
SwarmCD detects change (≤45 seconds)
↓
Clone repo → parse config → decrypt secrets (SOPS + age)
↓
docker stack deploy
↓
Traefik picks up new service (if labels changed)
No CI/CD pipeline. No webhooks. No manual SSH. Deployment requires only a Git push.
What is SwarmCD?
SwarmCD is a lightweight GitOps controller for Docker Swarm. It does one thing: watch a Git repository and deploy Docker Compose stacks when files change.
Why not ArgoCD? ArgoCD is excellent for Kubernetes. It has no Swarm support. Its Kubernetes dependency alone would require more RAM than the entire rest of this stack combined.
Why not Portainer? Portainer has a GitOps feature, but it requires a paid Business license for the functionality we need, and it runs as a much heavier process.
SwarmCD has no web UI complexity, no operator pattern, no CRDs. It reads a config YAML, polls Git, and runs docker stack deploy. This simplicity is intentional at this scale.
The SwarmCD service is deployed by Ansible during bootstrap (see Part 2) as the only service that isn't self-managed via GitOps. Everything else is managed by SwarmCD from Git.
SwarmCD Configuration
The full configuration lives at apps/swarmcd/config.yaml:
update_interval: 45
repos_path: repos/
sops_secrets_discovery: true
auto_rotate: true
repos:
my-infra:
url: "https://gitlab.com/<YOUR_GITLAB_USERNAME>/<YOUR_REPO_NAME>.git"
username: does_not_matter
password_file: /secrets/gitlab_token
stacks:
traefik:
repo: my-infra
branch: main
compose_file: /apps/traefik/traefik.yaml
sops_files:
- /apps/traefik/crowdsec_api_key
bento:
repo: my-infra
branch: main
compose_file: /apps/bento/bento.yaml
alloy:
repo: my-infra
branch: main
compose_file: /apps/monitoring/alloy.yaml
sops_files:
- /apps/monitoring/grafana_cloud_passwd
cadvisor:
repo: my-infra
branch: main
compose_file: /apps/monitoring/cadvisor.yaml
node_exporter:
repo: my-infra
branch: main
compose_file: /apps/monitoring/node_exporter.yaml
address: 0.0.0.0:8080
Key settings explained:
update_interval: 45 — SwarmCD polls Git every 45 seconds. This is a tradeoff: shorter intervals mean faster deployments but more Git API requests. 45 seconds is sufficient for most infrastructure change workflows.
sops_secrets_discovery: true — SwarmCD automatically finds and decrypts SOPS-encrypted files listed in sops_files. The decrypted content is passed to Docker as secrets.
auto_rotate: true — This is the most significant setting. See the next section.
password_file: /secrets/gitlab_token — The GitLab personal access token is mounted as a Docker secret (created by Ansible during bootstrap). SwarmCD reads it from the filesystem, never from environment variables.
The Immutable Config Problem (and the Solution)
Docker Swarm has a strict rule: configs and secrets are immutable once created. You cannot update a config in place. If you change a Traefik config file, you must:
- Create a new config object with a different name
- Update the service to reference the new config
- (Optionally) delete the old config
This is different from Kubernetes ConfigMaps, which can be updated in place. Docker's immutability is a feature — it means config history is preserved and rollback is possible — but it requires tooling support.
SwarmCD's auto_rotate: true handles this automatically:
- You push a change to
apps/traefik/traefik_static_conf.yaml - SwarmCD detects the change
- It creates a new Docker config:
traefik_static_conf_<hash>(where hash is derived from the content) - It updates the Traefik service to mount the new config instead of the old one
- Docker Swarm rolls out the update (Traefik restarts with the new config)
- The old config remains until you clean it up (SwarmCD can do this automatically)
Without auto_rotate, you'd need to manually rename config objects on every change. With it, the whole process is transparent.
SOPS + age: Secrets in Git
Storing secrets in Git is a security risk without encryption. SOPS (Secrets OPerationS) addresses this by encrypting secret files before they are committed.
How it works
age is an asymmetric encryption tool. You generate a keypair:
age-keygen -o ~/.age/key.txt
The public key goes into a .sops.yaml file in your repository:
creation_rules:
- path_regex: .*
age: age1youragepublickey...
To encrypt a secret:
echo "my-secret-value" | sops --encrypt --input-type=binary --output-type=binary /dev/stdin > apps/traefik/crowdsec_api_key
The encrypted file is committed to Git. The private age key (~/.age/key.txt) is never committed.
How SwarmCD decrypts
During the Ansible bootstrap (Part 2), the age private key is stored as a Docker secret named age_key. SwarmCD mounts this secret and sets the SOPS_AGE_KEY_FILE environment variable to point to it:
# apps/swarmcd/swarmcd.yaml (relevant excerpt)
services:
swarmcd:
environment:
SOPS_AGE_KEY_FILE: /secrets/age_key
secrets:
- age_key
When SwarmCD processes a stack that has sops_files, it:
- Finds each listed file in the cloned repo
- Decrypts it using the age key
- Creates a Docker secret with the decrypted content
- References that secret in the service deployment
The decrypted value exists only in Docker's encrypted secret store and in the container's memory at runtime. It never touches the server's filesystem in plaintext.
Two-layer secret model
| Layer | Where stored | Used by | Contents |
|---|---|---|---|
| SOPS/vault.yaml | Git (encrypted) | Terraform at provisioning time | Cloud API keys, admin password hash |
| Docker secrets | Docker Swarm secret store | Running containers | App credentials (GitLab token, Grafana password, CrowdSec API key) |
Terraform-layer secrets are only present during tofu apply. They are never present in a running container. Docker-layer secrets are created once (by Ansible or by SwarmCD) and mounted into containers as files.
Adding a New App
To add a new service to the GitOps setup:
-
Write a Docker Compose file and add it to
apps/yourapp/yourapp.yaml -
Add a stack entry to
apps/swarmcd/config.yaml:
stacks:
yourapp:
repo: my-infra
branch: main
compose_file: /apps/yourapp/yourapp.yaml
- Run the Ansible playbook to deploy the updated SwarmCD configuration to the server:
cd server && ansible-playbook setup_docker.yaml
SwarmCD's config.yaml is mounted into the container as a Docker config object. Modifying it in Git is not sufficient — Ansible must redeploy SwarmCD with the updated config so the new stack entry takes effect.
- Add Traefik labels to expose the service (if needed):
# In yourapp.yaml
services:
yourapp:
labels:
- "traefik.enable=true"
- "traefik.http.routers.yourapp.rule=Host(`yourapp.yourdomain.com`)"
- "traefik.http.routers.yourapp.tls.certresolver=production"
- "traefik.http.services.yourapp.loadbalancer.server.port=8080"
-
Add a Cloudflare DNS record in root
main.tf(and runtofu apply) - Push to Git — SwarmCD deploys it within 45 seconds
This is the complete workflow. No manual Docker commands, no state to maintain outside of Git.
What SwarmCD Does NOT Do
SwarmCD has limitations worth addressing explicitly, since GitOps tooling often implies more sophisticated features:
No health-check-based rollbacks. If a new deployment causes container crashes, SwarmCD will not automatically roll back. Docker Swarm's restart policy (on-failure) will restart failed containers, and start-first update strategy (configured in Traefik) minimizes downtime, but there's no automatic rollback to a previous Git commit.
Mitigation: If a deployment breaks, git revert and push. SwarmCD will redeploy the reverted config within 45 seconds.
No diff preview. SwarmCD doesn't show you what will change before deploying.
Mitigation: Git PR review serves this function.
No multi-cluster support. SwarmCD manages one Swarm from one config file. This is fine for a single-server setup.
For a small production setup, none of these limitations prevent adoption. The simplicity SwarmCD offers in exchange for these features is an appropriate tradeoff at this scale.
The Complete Deployment Flow
Here's every step that happens when you push a change to apps/traefik/traefik_static_conf.yaml:
1. git push origin main
└── GitLab receives the commit
2. SwarmCD polls (within 45 seconds)
└── Detects diff in apps/traefik/traefik_static_conf.yaml
3. Clone / pull latest main branch
4. Parse apps/swarmcd/config.yaml
└── traefik stack → compose_file: /apps/traefik/traefik.yaml
5. Decrypt SOPS secrets
└── apps/traefik/crowdsec_api_key → decrypted value
6. Create versioned Docker objects
├── Config: traefik_static_conf_abc123 (new content hash)
├── Config: traefik_dynamic_conf_def456 (unchanged, reuse or recreate)
└── Secret: crowdsec_api_key_ghi789
7. docker stack deploy traefik
└── Service update: new config references
8. Docker Swarm rolls out update
└── Traefik restarts with new static config
9. Traefik re-reads config
├── New entrypoint settings take effect
└── New ACME resolver (if changed) initializes
10. Monitoring captures the event
└── Alloy ships container restart log to Grafana Cloud Loki
Total time from git push to running: approximately 45-90 seconds.
Summary
The GitOps loop in this stack is simple by design:
-
SwarmCD polls Git every 45 seconds and runs
docker stack deployon changes -
auto_rotatehandles Docker Swarm's immutable config requirement transparently - SOPS + age lets secrets live in Git safely — encrypted at commit time, decrypted at deploy time by SwarmCD using a Docker-managed age key
- No pipeline required — the deployment trigger is a Git push, not a CI job
The following part covers how public traffic is routed and secured.
Repository: https://gitlab.com/sakonn/docker-swarm-gitops
Top comments (0)