Skip to content
Getting started

Getting started

What you need first

  • A Docker Swarm cluster (swarmctl calls manager-only APIs — service list, inspect, update — so it must be scheduled on a manager).
  • A Cloudflare account with a named tunnel already created, and cloudflared running somewhere in the cluster with that tunnel’s token. swarmctl edits the tunnel’s ingress configuration; it does not run the tunnel.
  • A Cloudflare global API key and the account email (not an API token — see Configuration).
  • Pushover application key + recipient, and a Discord webhook URL. Both are currently required at startup.

Deploy it

swarmctl ships as ghcr.io/alexraskin/swarmctl:latest. The image listens on 9000 and healthchecks itself against /ping.

# docker-compose.swarmctl.yml — deploy with: docker stack deploy -c … swarmctl
services:
  server:
    image: ghcr.io/alexraskin/swarmctl:latest
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
    environment:
      ENVIROMENT: production          # spelled this way in the code
      AUTH_TOKEN: /run/secrets/swarmctl_auth_token
      CLOUDFLARE_API_KEY: /run/secrets/cloudflare_api_key
      CLOUDFLARE_API_EMAIL: you@example.com
      CLOUDFLARE_ACCOUNT_ID: /run/secrets/cloudflare_account_id
      CLOUDFLARE_TUNNEL_ID: /run/secrets/cloudflare_tunnel_id
      PUSHOVER_API_KEY: /run/secrets/pushover_api_key
      PUSHOVER_RECIPIENT: /run/secrets/pushover_recipient
      WEBHOOK_URL: /run/secrets/discord_webhook_url
      SERVICE_REMOVAL_DELAY_MINUTES: "30"
      DELETE_DNS_ON_REMOVAL: "false"
    secrets:
      - swarmctl_auth_token
      - cloudflare_api_key
      - cloudflare_account_id
      - cloudflare_tunnel_id
      - pushover_api_key
      - pushover_recipient
      - discord_webhook_url
    deploy:
      placement:
        constraints:
          - node.role == manager
      labels:
        - "cloudflared.tunnel.enabled=true"
        - "cloudflared.tunnel.port=9000"
        - "cloudflared.tunnel.hostname=swarmctl.example.com"

secrets:
  swarmctl_auth_token:
    external: true
  # …and the rest

A worked example lives in alexraskin/infrastructure.

Any variable whose value starts with / and resolves to an existing file is read from that file and trimmed — that is how the Docker secret paths above work. Anything else is used literally, so plain env vars are fine for development. See Configuration.

Note that swarmctl labels itself for the tunnel in this example. That is the normal setup: it publishes its own hostname on first sync, and then CI reaches it there.

First call

curl -X POST -H "Authorization: Bearer $AUTH_TOKEN" \
  "https://swarmctl.example.com/v1/update/web_server?image=ghcr.io/you/web:sha-abc123"
{ "success": true, "oldVersion": 412, "newVersion": 413 }

web_server is the Swarm service name (<stack>_<service>), not the stack name and not a container name. The endpoint changes only the image in the service’s container spec and re-submits the existing spec, so replica counts, labels, mounts, and networks are untouched.

Health and version need no token:

curl https://swarmctl.example.com/ping       # "."
curl https://swarmctl.example.com/version

Give a service a hostname

Add labels to any service in the swarm and redeploy it:

deploy:
  labels:
    - "cloudflared.tunnel.enabled=true"
    - "cloudflared.tunnel.port=80"
    - "cloudflared.tunnel.hostname=app.example.com"

On the resulting Docker service update event swarmctl adds app.example.com → http://<serviceName>:80 to the tunnel ingress and creates a proxied CNAME to <tunnel-id>.cfargotunnel.com. Full contract in Service labels.

Labels must be under deploy.labels (service labels), not the top-level labels: key, which sets container labels. swarmctl reads Service.Spec.Labels, so container labels are invisible to it.

Run it locally

git clone https://github.com/alexraskin/swarmctl && cd swarmctl
mise trust && mise install
cp .env.example .env      # fill in every value
mise run run              # go run . -port 8080 -debug

The process needs a reachable Docker socket, and it calls os.Exit(-1) at startup if any required variable is missing. -debug turns on the DEBUG-level logs, which is where all the reconciler detail goes.