Docker · Deployment

Docker Swarm and Traefik

Deploy a container behind HTTPS, then reuse the same stack for branch previews such as feature-login.myapp.dev.my-domain.com.

This is a minimal single-manager setup. Point DNS to the server, open ports 80 and 443, and keep the Docker socket private. Access to that socket grants control of the host.

1. Prepare Swarm and DNS

Initialize Swarm on the server and create one shared overlay network:

docker swarm init --advertise-addr <server-ip>
docker network create --driver overlay --attachable traefik-public
docker node ls

Create DNS records pointing to the server. The wildcard covers branch hosts for every app under dev.my-domain.com:

app.my-domain.com             A  203.0.113.10
*.dev.my-domain.com           A  203.0.113.10

A more specific DNS record or delegation can take precedence. This is a DNS wildcard, not a TLS wildcard certificate. Traefik requests a certificate for each complete hostname through HTTP-01.

Multi-node swarms also need TCP 2377, TCP/UDP 7946, and UDP 4789 between nodes. Do not expose those ports to the public internet.

2. Run Traefik

Save this as traefik-stack.yml. Replace the ACME email before deploying.

services:
  traefik:
    image: traefik:v3.7.7@sha256:1cb3845d7a05e1473c9086351426597e911db49db382b6e4769f9b0744962ac8
    command:
      - --providers.swarm=true
      - --providers.swarm.exposedbydefault=false
      - --providers.swarm.network=traefik-public
      - --entrypoints.web.address=:80
      - --entrypoints.web.http.redirections.entrypoint.to=websecure
      - --entrypoints.web.http.redirections.entrypoint.scheme=https
      - --entrypoints.websecure.address=:443
      - --certificatesresolvers.letsencrypt.acme.email=ops@my-domain.com
      - --certificatesresolvers.letsencrypt.acme.storage=/data/acme.json
      - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - traefik-data:/data
    networks:
      - traefik-public
    deploy:
      replicas: 1
      placement:
        constraints:
          - node.role == manager

volumes:
  traefik-data:

networks:
  traefik-public:
    external: true
docker stack deploy -c traefik-stack.yml traefik
docker stack services traefik
docker service logs -f traefik_traefik

3. Route an application

Save this as app-stack.yml. Swarm reads Traefik labels from deploy.labels, and Traefik needs the container’s internal port.

services:
  web:
    image: ${APP_IMAGE}
    networks:
      - traefik-public
    deploy:
      replicas: 2
      labels:
        - "traefik.enable=true"
        - "traefik.swarm.network=traefik-public"
        - "traefik.http.routers.${ROUTE_ID}.rule=Host(`${APP_HOST}`)"
        - "traefik.http.routers.${ROUTE_ID}.entrypoints=websecure"
        - "traefik.http.routers.${ROUTE_ID}.tls=true"
        - "traefik.http.routers.${ROUTE_ID}.tls.certresolver=letsencrypt"
        - "traefik.http.routers.${ROUTE_ID}.service=${ROUTE_ID}"
        - "traefik.http.services.${ROUTE_ID}.loadbalancer.server.port=8000"

networks:
  traefik-public:
    external: true

Deploy an image that is already available from a registry:

export ROUTE_ID=myapp
export APP_HOST=app.my-domain.com
export APP_IMAGE=ghcr.io/example/myapp:sha-abc1234

docker stack deploy --with-registry-auth -c app-stack.yml myapp
docker stack services myapp
curl -I https://app.my-domain.com

4. Deploy one branch

Give the branch its own stack, router, hostname, and immutable image tag. Publish the image before running this command.

APP=myapp
BRANCH_SLUG="$(git branch --show-current \
  | tr '[:upper:]' '[:lower:]' \
  | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g')"

export ROUTE_ID="${APP}-${BRANCH_SLUG}"
export APP_HOST="${BRANCH_SLUG}.${APP}.dev.my-domain.com"
export APP_IMAGE="ghcr.io/example/${APP}:sha-$(git rev-parse --short HEAD)"

docker stack deploy --with-registry-auth \
  -c app-stack.yml "${ROUTE_ID}"

echo "https://${APP_HOST}"

The stack name keeps Swarm resources separate. The unique label names prevent one preview router from replacing another in Traefik.

docker stack ps "${ROUTE_ID}"
docker service logs -f "${ROUTE_ID}_web"
docker stack rm "${ROUTE_ID}"