Zero-downtime deploys of Docker Compose services to your own server, straight from GitHub Actions.

The action connects over SSH, uploads your compose/env files, pulls images, runs migrations and updates services with docker-rollout: the new container is started next to the old one, and the old one is removed only after the new one is healthy. A release that fails its healthcheck is rolled back automatically. The old containers keep serving traffic the whole time.

No Kubernetes, no Swarm, no agents on the server. You need Docker, a reverse proxy (Traefik, nginx-proxy, Caddy…) and SSH access.

- uses: mtizima/docker-rollout-action@v1

with:

host: ${{ secrets.SSH_HOST }}

user: deploy

ssh-key: ${{ secrets.SSH_KEY }}

known-hosts: ${{ secrets.SSH_KNOWN_HOSTS }}

project-dir: /opt/myapp

services: web- Zero downtime, verified in CI: every change is deployed under constant load and the test fails if a single request fails.

- Automatic rollback: if the new containers never become healthy, they are removed and the old ones keep running. The step fails, so you get notified.

- Migrations: pre-deployruns after the pull and before the switch, and a failure aborts the deploy.

- Handles the whole stack: services with container_name/ports(workers, databases, the proxy itself) are updated with a regulardocker compose up -d.

- Secure by default: the host key is pinned, secrets are never put on a command line, and

registry credentials live in a temporary DOCKER_CONFIGthat is deleted after the deploy.

- One SSH session, plain Bash, no Node.js, nothing to install on the runner. The docker-rollout plugin is installed on the server automatically if it is missing.

- Readable logs: every stage is a collapsible group, and the job summary shows what was deployed.

Build images in the workflow, then deploy with migrations:

name: Deploy

on:

push:

branches: [main]

concurrency:

group: deploy-production

cancel-in-progress: false

permissions:

contents: read

packages: write

jobs:

deploy:

runs-on: ubuntu-latest

environment: production

steps:

- uses: actions/checkout@v5

- uses: docker/login-action@v4

with:

registry: ghcr.io

username: ${{ github.actor }}

password: ${{ secrets.GITHUB_TOKEN }}

- uses: docker/build-push-action@v7

with:

push: true

tags: ghcr.io/${{ github.repository }}:latest

- uses: mtizima/docker-rollout-action@v1

with:

host: ${{ secrets.SSH_HOST }}

user: ${{ secrets.SSH_USER }}

ssh-key: ${{ secrets.SSH_KEY }}

known-hosts: ${{ secrets.SSH_KNOWN_HOSTS }}

project-dir: /opt/myapp

# local path : path on the server (relative to project-dir)

files: |

deploy/compose.prod.yml:compose.yml

registry: ghcr.io

registry-username: ${{ github.actor }}

registry-password: ${{ secrets.GITHUB_TOKEN }}

pre-deploy: docker compose run --rm web ./manage.py migrate

services: web

up-services: worker redis

up-flags: --remove-orphans

pre-stop-hook: touch /tmp/drain && sleep 10

healthcheck: requireAll steps run in one SSH session inside project-dir:

- Upload files.

- Install the docker-rollout plugin if it is missing.

- Log into registry(credentials go to a temporary Docker config).

- docker compose pullfor- servicesand- up-services.

- Check that serviceshave a healthcheck (seehealthcheck).

- Run pre-deploy.

- docker rollout <service>for each of- services, one by one.

- docker compose up -dfor- up-services.

- Run post-deploy, anddocker image prune -fifpruneis enabled.

Any failure stops the deploy and fails the step.

At least one of services and up-services must be set.

Server: Docker with the Compose v2 plugin, bash 4.4+, curl or wget (only to install

docker-rollout), and an SSH user in the docker group.

Runner: any Linux runner with ssh and base64 (e.g. ubuntu-latest).

Services in services (docker-rollout limitations):

- no container_nameand no publishedports, because two containers of the service run side by side during the deploy. Put a reverse proxy in front and route to the service through it;

- a Docker healthcheck. Without one, docker-rollout only waits waitseconds and cannot know whether the new container is ready to take traffic.

A healthcheck makes sure traffic goes to the new container only once it is ready. The old

container still needs to be taken out of the proxy before it stops. Otherwise requests

in flight at that moment fail. Our own tests show this: without draining, every deploy dropped

a few requests with 502. With draining, zero requests were dropped.

Make the healthcheck fail while /tmp/drain exists:

services:

web:

image: ghcr.io/me/web:latest

healthcheck:

test: test ! -f /tmp/drain && curl -fsS http://localhost:8000/health

interval: 5s

retries: 1

labels:

traefik.enable: "true"

traefik.http.routers.web.rule: Host(`example.com`)and deploy with:

pre-stop-hook: touch /tmp/drain && sleep 10The sleep must be longer than interval × retries plus the time needed to finish open requests.

See the docker-rollout docs for details.

- Always set known-hosts. Get the value once from a trusted machine:ssh-keyscan -p 22 your.server→ save it as theSSH_KNOWN_HOSTSsecret.

- Use a dedicated deploy user and key. Note that membership in the dockergroup is root-equivalent on that server.

- Input values are sent to the server in the SSH session's stdin, never as command-line

arguments, so they don't show up in pson either side.registry-passwordis masked in the logs.

test/run-local.sh spins up a throwaway SSH "server" container, a password-protected registry and

Traefik on your local Docker, then runs the scenarios: first deploy, deploy under load (must drop

zero requests), a broken release (must fail, roll back and still drop zero requests), side effects

(no credentials left behind, etc.) and input validation. CI runs the same scenarios through the

real action.

bash test/run-local.shMIT. docker-rollout itself is © Karol Musur, MIT.