DEV Community

Jakub Korečko
Jakub Korečko

Posted on

Part 3: Push to Git, Get a Deployment — SwarmCD + SOPS on Docker Swarm

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 push to 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

  1. Create a new config object with a different name
  2. Update the service to reference the new config
  3. (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:

  1. You push a change to apps/traefik/traefik_static_conf.yaml
  2. SwarmCD detects the change
  3. It creates a new Docker config: traefik_static_conf_<hash> (where hash is derived from the content)
  4. It updates the Traefik service to mount the new config instead of the old one
  5. Docker Swarm rolls out the update (Traefik restarts with the new config)
  6. 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
Enter fullscreen mode Exit fullscreen mode

The public key goes into a .sops.yaml file in your repository:

creation_rules:
  - path_regex: .*
    age: age1youragepublickey...
Enter fullscreen mode Exit fullscreen mode

To encrypt a secret:

echo "my-secret-value" | sops --encrypt --input-type=binary --output-type=binary /dev/stdin > apps/traefik/crowdsec_api_key
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

When SwarmCD processes a stack that has sops_files, it:

  1. Finds each listed file in the cloned repo
  2. Decrypts it using the age key
  3. Creates a Docker secret with the decrypted content
  4. 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:

  1. Write a Docker Compose file and add it to apps/yourapp/yourapp.yaml
  2. Add a stack entry to apps/swarmcd/config.yaml:
   stacks:
     yourapp:
       repo: my-infra
       branch: main
       compose_file: /apps/yourapp/yourapp.yaml
Enter fullscreen mode Exit fullscreen mode
  1. Run the Ansible playbook to deploy the updated SwarmCD configuration to the server:
   cd server && ansible-playbook setup_docker.yaml
Enter fullscreen mode Exit fullscreen mode

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.

  1. 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"
Enter fullscreen mode Exit fullscreen mode
  1. Add a Cloudflare DNS record in root main.tf (and run tofu apply)
  2. 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
Enter fullscreen mode Exit fullscreen mode

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 deploy on changes
  • auto_rotate handles 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)