DEV Community

Jakub Korečko
Jakub Korečko

Posted on

I Run a Full GitOps Stack for $6/month — Here's the Architecture

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:

  1. Detects the change within 45 seconds
  2. Decrypts any secrets that were updated
  3. Deploys the new service configuration to Docker Swarm
  4. Routes traffic through Traefik (TLS terminated, WAF filtered)
  5. Ships metrics and logs to Grafana Cloud

From tofu apply (a one-time operation), the stack:

  1. Provisions a hardened Ubuntu 24.04 server on Hetzner
  2. Configures firewall rules, SSH hardening, fail2ban
  3. Installs Docker, initializes a Swarm
  4. Joins Tailscale VPN (for secure admin access without exposing SSH publicly)
  5. Creates Cloudflare DNS records
  6. 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)
Enter fullscreen mode Exit fullscreen mode

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

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)