All posts
6 min readRegan Lawton

Per-PR preview environments on Docker Swarm

How to give every pull request its own live URL using Docker Swarm, Traefik, and a wildcard DNS record, without running a Kubernetes cluster.

Per-PR preview environments on Docker Swarm

QA needs to test your PR. The obvious answer is to tell them to check it out locally, spin up the service, and run it against their dev environment. That works right up until the service is real.

Say the backend is a FastAPI service with a Celery worker, a Redis dependency, and a handful of env vars that aren’t in the repo. Ask a designer to run that locally and you’ve lost their afternoon. Ask a QA engineer and you’ve tied up time that should go toward running tests, not debugging Docker volumes.

Staging is the other option, but staging is shared. You can’t push three branches to one environment and know which one caused the regression. It’s also where production-like concerns like database migrations need their own attention.

The alternative is to give every PR its own live URL. It goes up when the PR opens, updates on every push, and disappears when the PR closes. No local setup, no staging contention, just a link in the PR comments that anyone on the team can open.

You don’t need Kubernetes for this

Most writing about preview environments assumes you’re running Kubernetes: Argo CD, Helm charts, one namespace per branch. That’s the right setup if you have the team to operate it.

For a small team that doesn’t want to manage a cluster, Docker Swarm is enough. It runs on a single machine or spans a cluster, and it gives you named stacks, rolling updates, and a place to attach Traefik labels.

What you need is for each PR’s stack to be discoverable at a unique URL without manual DNS changes per deployment. A wildcard DNS record handles that. Point *.api.example.dev at your server’s IP and every subdomain resolves to the same machine. Traefik then decides which backend to send each request to based on a Host label on the container.

labels:
  - traefik.enable=true
  - traefik.swarm.network=edge
  - traefik.http.routers.${STACK_NAME}.rule=Host(`${PREVIEW_HOST}`)
  - traefik.http.routers.${STACK_NAME}.entrypoints=websecure
  - traefik.http.routers.${STACK_NAME}.tls=true
  - traefik.http.routers.${STACK_NAME}.tls.certresolver=letsencrypt
  - traefik.http.services.${STACK_NAME}.loadbalancer.server.port=8000

pr-42.api.example.dev goes to the stack for PR 42, pr-43.api.example.dev goes to PR 43. No config changes per deployment, no Traefik restarts.

The GitHub Action does one thing per event

When a PR is opened or pushed to, the action builds and deploys. When the PR is closed, it tears down.

The naming convention is deterministic from the PR number: stack name api-pr-{number}, image tag api-pr-{number}-{short-sha}, preview host pr-{number}.api.example.dev.

- name: Set preview variables
  id: vars
  run: |
    PR_NUMBER=${{ github.event.pull_request.number }}
    SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7)
    STACK_NAME="api-pr-${PR_NUMBER}"
    IMAGE="myorg/api-service:pr-${PR_NUMBER}-${SHORT_SHA}"
    PREVIEW_HOST="pr-${PR_NUMBER}.api.example.dev"

    echo "stack_name=${STACK_NAME}" >> $GITHUB_OUTPUT
    echo "image=${IMAGE}" >> $GITHUB_OUTPUT
    echo "preview_host=${PREVIEW_HOST}" >> $GITHUB_OUTPUT
    echo "preview_url=https://${PREVIEW_HOST}" >> $GITHUB_OUTPUT

After building and pushing the image, the action SSHes into the Swarm host and runs two commands:

docker stack deploy \
  --with-registry-auth \
  -c stacks/api-preview.yml \
  "$STACK_NAME"

docker service update \
  --force \
  --image "$IMAGE" \
  --detach=false \
  "${STACK_NAME}_api-preview"

The --force on service update isn’t optional. Without it, Docker won’t restart the service if the stack definition didn’t change between pushes, even when the image did. The update blocks until the new container is running.

Once the deploy completes, a GitHub Actions bot comments the preview URL directly on the PR. On subsequent pushes it updates the same comment.

Cleanup is where these systems break

Preview environments that don’t clean up are worse than none. Stacks accumulate, and two weeks later you’ve got thirty running services for PRs that closed last month.

The teardown job is one command:

docker stack rm "$STACK_NAME" || echo "Stack not found, skipping"

It runs on every PR close event, whether the PR was merged or abandoned. The fallback keeps the job from failing if the stack was never deployed, say the initial build failed.

This is the part worth testing explicitly. Trigger a PR close on a branch that never deployed and confirm the job exits cleanly. Small teams skip this and then wonder why their server is out of memory three months later.

Preview environments are not staging

Preview environments sit between a developer’s laptop and staging. They aren’t a replacement for either.

Staging runs against production-like data. Migrations go there first. Observability points at it, and integration failures show up there before they reach users.

Preview environments answer a narrower question: does this specific change behave correctly in a real runtime, before it joins the shared queue? That is a smaller test than whether the whole system is ready to scale under production constraints, but it catches integration failures that only show up in a real runtime, not on a laptop and not on a branch you cannot isolate.

A frontend engineer can point their local UI at the preview URL and test the API integration without coordinating access to staging. A QA engineer can verify a new endpoint returns the right shape without checking out the branch. That’s the value.

Feature flags still have a role. A flag controls rollout to real users after something ships; a preview environment lets the team verify the thing works before it ships. They solve different problems, and knowing which one you’re solving matters before you pick the tool.

What this costs

It costs one wildcard DNS record and a Traefik instance already running as a Swarm service.

Swarm isn’t limited to a single machine. It’s a cluster orchestrator, and Traefik routes across every node the same way it routes on one. Start with a single server and add nodes when load demands it. Nothing in this setup needs to change when you do.

The per-preview cost is memory. A Python API service sitting idle uses a few hundred MB, and a worker next to it a few hundred more. Ten open PRs means ten stacks, roughly two containers each, and a single four-core box with 8GB handles that comfortably.

What it avoids is the managed Kubernetes control plane, the node group configuration, the Helm charts, and the time a small team spends becoming platform engineers before they’ve shipped anything.

The real question is when to revisit this

Docker Swarm isn’t where container orchestration is heading. Kubernetes has primitives this setup doesn’t, but they come with a cost, and none of it matters until you need it.

A team of five or six doesn’t need those primitives yet. They need PRs that can be tested by someone who isn’t the author, in a real environment, without coordination overhead.

When the team grows to the point where Swarm’s limitations become the constraint, that’s when Kubernetes makes sense.

Until then, this works.