What you'll learn:
- What this stack does and what problem it solves
- Why Docker Swarm instead of Kubernetes
- Why Hetzner and why GitOps
- The full tech stack with tool-by-tool rationale
- How the repository is structured
The Problem
Every side project or small production deployment starts the same way: you SSH into a server, run some commands, tweak a config, and hope you remember what you did. Six months later something breaks and you can't reproduce the setup. You're firefighting with no audit trail and no way to roll back.
The solution is GitOps — your infrastructure and application configuration live in a Git repository, and changes to that repository automatically trigger deployments. Git becomes your source of truth, your audit log, and your rollback mechanism.
The problem is that most GitOps tutorials assume you're running Kubernetes. If you're running a small project on a single server, Kubernetes is enormous overkill.
This series shows a different approach: a full GitOps pipeline on Docker Swarm, running on a single Hetzner server for roughly €5/month. No cluster management. No operator madness. Just Git, Docker, and a handful of focused tools.
What the Stack Does
From a single git push, the stack:
- Detects the change within 45 seconds
- Decrypts any secrets that were updated
- Deploys the new service configuration to Docker Swarm
- Routes traffic through Traefik (TLS terminated, WAF filtered)
- Ships metrics and logs to Grafana Cloud
From tofu apply (a one-time operation), the stack:
- Provisions a hardened Ubuntu 24.04 server on Hetzner
- Configures firewall rules, SSH hardening, fail2ban
- Installs Docker, initializes a Swarm
- Joins Tailscale VPN (for secure admin access without exposing SSH publicly)
- Creates Cloudflare DNS records
- Bootstraps the GitOps controller (SwarmCD)
After that initial apply, the server is fully hands-off. All future changes go through Git.
Architecture Overview
┌─────────────────────────────────────────────────────┐
│ Your Local Machine │
│ │
│ git push ──────────────────────────► GitLab Repo │
│ │ │
│ tofu apply ──► Hetzner + Cloudflare │ │
└─────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────┐
│ Hetzner Cloud Server │
│ │
│ ┌─────────────┐ polls every 45s │
│ │ SwarmCD │ ◄──────────────── GitLab Repo │
│ └──────┬──────┘ │
│ │ docker stack deploy │
│ ▼ │
│ ┌─────────────────────────────────────────────┐ │
│ │ Docker Swarm │ │
│ │ │ │
│ │ Traefik ──► CrowdSec ──► Apps │ │
│ │ (reverse proxy + WAF) (bento, etc.) │ │
│ │ │ │
│ │ Monitoring (Alloy, cAdvisor, node-exporter)│ │
│ └─────────────────────────────────────────────┘ │
│ │
│ Tailscale VPN ──── Admin Access (no public SSH) │
│ │
│ Hetzner Volume (10GB persistent storage) │
└─────────────────────────────────────────────────────┘
│ metrics + logs
▼
Grafana Cloud (free tier)
Why Docker Swarm, Not Kubernetes
"Why not just use Kubernetes?"
This is the first question you may ask, so let's address it directly.
Kubernetes is excellent for teams managing dozens of services across multiple nodes. For a single server with five services, it introduces:
- A control plane that consumes ~1-2GB RAM before your apps even start
- A separate installation process (kubeadm, k3s, minikube, etc.)
- A completely different mental model for networking, storage, and secrets
- Custom Resource Definitions, operators, Helm charts, and dozens of YAML abstractions
Docker Swarm, by contrast:
- Is built into Docker — one command to initialize:
docker swarm init - Uses the same Docker Compose format you already know
- Has native support for secrets and configs
- Consumes essentially no overhead on a single-node setup
- Handles rolling updates and zero-downtime deployments without configuration
The tradeoff: Docker Swarm has fewer features, smaller ecosystem, and doesn't scale horizontally as easily as Kubernetes. For a single server hosting a handful of services, none of that matters.
Key insight: Docker Swarm is not "Kubernetes lite" — it's a different tool for a different scale. Choosing the right tool for the scale you actually have is good engineering.
Why Hetzner
Hetzner is a German cloud provider with data centers in Nuremberg, Falkenstein, and Helsinki. The cx23 instance used in this stack costs approximately €4-5/month:
| Spec | Value |
|---|---|
| CPU | 2 vCPU (shared) |
| RAM | 4 GB |
| Storage | 40 GB SSD (+ 10 GB volume) |
| Network | 20 TB/month included |
| Location | Nuremberg, Germany |
For comparison, a comparable AWS EC2 instance (t3.medium) costs roughly $30/month. The performance difference for a hobby or small production workload is negligible.
Hetzner is also EU-based and GDPR-compliant — relevant if you're handling user data from European users.
Why GitOps
GitOps solves three problems that every self-hosted deployment eventually hits:
1. No reproducibility. "I set this up six months ago and I don't remember what I did" is the death of many side projects. When infrastructure and app config live in Git, any team member (or future you) can re-provision from scratch.
2. No audit trail. Who changed what and when? Git history answers this. Every deployment is a commit with a timestamp and author.
3. Config drift. The server diverges from what you think is deployed. GitOps eliminates drift by making Git the authoritative source. The GitOps controller continuously reconciles what's running with what's in Git.
The GitOps controller in this stack is SwarmCD. It polls the Git repository every 45 seconds and deploys any changed stacks. You push to Git; SwarmCD handles the rest.
The Full Tech Stack
| Tool | Role | Why This One |
|---|---|---|
| OpenTofu | Infrastructure as Code | Open-source Terraform fork; provisions Hetzner, Cloudflare |
| Hetzner Cloud | Server hosting | Cheap, reliable, EU-based, good API |
| Cloudflare | DNS + CDN | Free tier, hides server IP, DDoS protection |
| cloud-init | OS bootstrapping | Runs on first boot; installs Docker, hardens SSH, joins Tailscale |
| Ansible | Bootstrap provisioning | One-time Docker setup (volumes, secrets, SwarmCD deploy) |
| Docker Swarm | Container orchestration | Built into Docker, no extra install, Compose-compatible |
| SwarmCD | GitOps controller | Lightweight, native Swarm support, SOPS integration |
| SOPS + age | Secret encryption | Encrypts secrets in Git; decrypted at deploy time |
| Traefik | Reverse proxy + TLS | Label-based routing, Let's Encrypt built in, Swarm-aware |
| CrowdSec | WAF + IDS | Community IP reputation list + AppSec rules via Traefik plugin |
| Tailscale | VPN | Secure admin access; no public SSH exposure |
| Grafana Alloy | Metrics + logs agent | Single container replaces Prometheus Agent + Promtail |
| Grafana Cloud | Observability backend | Free tier; managed Prometheus + Loki |
| fail2ban | SSH brute-force protection | Bans IPs after 3 failed SSH attempts |
Repository Structure
gitops/
├── vault.yaml.example # Template for SOPS-encrypted secrets
├── main.tf # Root: wires modules, Cloudflare DNS, remote state
├── providers.tf # Provider versions (SOPS, Cloudflare, Hcloud, Ansible)
├── variables.tf # Root variables
├── data.tf # Loads vault.yaml via SOPS provider
│
├── server/ # Hetzner provisioning module
│ ├── main.tf # Server, firewall, IPs, volume resources
│ ├── providers.tf # Local provider declarations
│ ├── variables.tf # Sensitive inputs (API keys, password hash)
│ ├── outputs.tf # Server IP, SSH port, Tailscale hostname
│ ├── hetzner.tfpl # cloud-init template (OS hardening + Docker)
│ ├── inventory.yaml # Dynamic Ansible inventory (reads Terraform state)
│ ├── ansible.cfg # Ansible config (points to inventory.yaml)
│ └── setup_docker.yaml # Ansible playbook (bootstrap Docker + SwarmCD)
│
└── apps/ # Docker Compose stacks (deployed by SwarmCD)
├── swarmcd/
│ ├── config.yaml # SwarmCD: which stacks to watch, poll interval
│ └── swarmcd.yaml # SwarmCD compose file (bootstrapped by Ansible)
├── traefik/
│ ├── traefik.yaml # Traefik compose file
│ ├── traefik_static_conf.yaml # Entrypoints, ACME, providers, plugins
│ └── traefik_dynamic_conf.yaml # Middlewares (auth, CrowdSec)
├── monitoring/
│ ├── alloy.yaml # Grafana Alloy compose file
│ ├── cadvisor.yaml # cAdvisor compose file
│ ├── node_exporter.yaml # node-exporter compose file
│ └── grafana_alloy.alloy # Alloy pipeline config
└── bento/
└── bento.yaml # Example app with Traefik labels
The split between server/ and apps/ reflects a key separation of concerns:
-
server/is infrastructure — runs once, creates the environment -
apps/is application configuration — changes continuously via GitOps
What's in Each Part
- Part 2: Infrastructure as Code — OpenTofu, Hetzner provisioning, cloud-init hardening, Ansible bootstrap
- Part 3: GitOps loop — SwarmCD, SOPS secret encryption, Docker Swarm immutable configs
- Part 4: Security — Traefik, CrowdSec WAF, Tailscale VPN
- Part 5: Observability — Grafana Alloy, Prometheus, Loki, Grafana Cloud free tier
All source code is in the repository linked at the end of this article. Each article walks through specific files with annotated code snippets.
Repository: gitlab.com/sakonn/docker-swarm-gitops
Top comments (0)