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}"