Flow Deploy includes a composite GitHub Action that handles host discovery, GHCR authentication, and SSH-based deployment. This guide walks through setting up automated deploys from CI.
The deploy pipeline:
- CI builds your Docker image and pushes to GHCR
- The deploy action discovers hosts from your
docker-compose.yml - For each host: authenticates with GHCR and runs
flow-deploy deploy(which handles git fetch and checkout internally)
On your deploy server:
- Docker and Docker Compose
- Traefik (or your reverse proxy) running
- Git (
flow-deployhandlesgit fetchand detached checkout internally during each deploy) flow-deployinstalled:
curl -fsSL https://deploy.flowcanon.com/install | shFlow Deploy takes over the update cycle — you still do the initial setup manually.
1. Clone your project on the server:
git clone git@github.com:yourorg/yourproject.git /srv/yourproject
cd /srv/yourproject2. Create your .env file:
cp .env.example .env
# Edit with your values3. Start the stack:
docker compose up -d4. Install flow-deploy:
curl -fsSL https://deploy.flowcanon.com/install | shFrom this point, CI handles all subsequent deploys.
Generate a deploy key:
ssh-keygen -t ed25519 -C "deploy@yourproject" -f deploy_key -N ""- Add
deploy_key.pubto the server's~/.ssh/authorized_keysfor your deploy user - Add
deploy_key(the private key) as a GitHub secret:DEPLOY_SSH_KEY
If you want to keep host details out of your compose file (recommended for public repos), create a GitHub environment and set these variables:
| Variable | Description | Example |
|---|---|---|
HOST_NAME |
Server hostname or IP | app-1.example.com |
HOST_USER |
SSH user | deploy |
SSH_PORT |
SSH port (omit for 22) | 2222 |
These override x-deploy values from your compose file. Set them in:
Settings > Environments > [your environment] > Environment variables
Your workflow needs environment: your-environment-name on the deploy job to access these.
| Secret | Description |
|---|---|
DEPLOY_SSH_KEY |
Private SSH key for the deploy user |
GITHUB_TOKEN |
Provided automatically — used for GHCR auth on the server |
Use the standalone action from flowcanon/deploy-action.
| Input | Required | Default | Description |
|---|---|---|---|
tag |
Yes | — | Image tag to deploy |
ssh-key |
Yes | — | SSH private key |
command |
No | script/prod |
Compose command |
host |
No | — | Override deploy host |
user |
No | — | Override deploy user |
ssh-port |
No | — | Override SSH port |
registry-token |
No | — | GHCR token for server auth |
name: Deploy
on:
push:
branches: [master]
env:
IMAGE: ghcr.io/yourorg/yourproject
jobs:
deploy:
runs-on: ubuntu-latest
environment: production
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE }}
tags: type=sha,prefix=
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
- uses: flowcanon/deploy-action@master
with:
tag: ${{ steps.meta.outputs.version }}
ssh-key: ${{ secrets.DEPLOY_SSH_KEY }}
host: ${{ vars.HOST_NAME }}
user: ${{ vars.HOST_USER }}
ssh-port: ${{ vars.SSH_PORT }}
registry-token: ${{ secrets.GITHUB_TOKEN }}For each host group discovered from your compose config:
- SSH agent — loads your deploy key
- Discover hosts — parses
docker-compose.ymlforx-deployanddeploy.*labels, groups services by(host, user, port, dir) - GHCR login — authenticates Docker on the server (and logs out after)
- Deploy — runs
flow-deploy deploy --tag <tag>on the server (git fetch and detached checkout are handled by the tool)
The action discovers where to deploy from your compose file:
x-deploy:
host: app-1.example.com
user: deploy
port: 22
dir: /srv/myappPriority order (highest wins):
- GitHub Actions variables (
vars.HOST_NAME,vars.HOST_USER,vars.SSH_PORT) - Per-service labels (
deploy.host,deploy.user,deploy.port,deploy.dir) x-deploytop-level defaults
This means you can keep x-deploy in your compose file for local/development use and override with GitHub variables for production — keeping real hostnames out of version control.
For services spread across multiple hosts:
x-deploy:
user: deploy
dir: /srv/myapp
services:
web:
labels:
deploy.role: app
deploy.host: web-1.example.com
worker:
labels:
deploy.role: app
deploy.host: worker-1.example.com
deploy.dir: /srv/workerThe action groups services by (host, user, port, dir) and deploys to each group sequentially.
To cut releases with binaries and changelogs, see the release workflow in this repo which uses:
salsify/action-detect-and-tag-new-versionfor version detectionsoftprops/action-gh-releasefor GitHub releases with musl/glibc binariesflowcanon/release-builderfor changelogs and version bumps
working tree is dirty — deploy aborted:
The server repo has uncommitted changes. SSH into the server and resolve manually — git status will show what's dirty.
unauthorized pulling from GHCR:
Pass registry-token: ${{ secrets.GITHUB_TOKEN }} to the deploy action. The job needs packages: write (or at least packages: read) permission.
flow-deploy: command not found:
Install it on the server: curl -fsSL https://deploy.flowcanon.com/install | sh
Then start a new login shell or run . ~/.profile.
Health check timeout:
Increase the timeout via label: deploy.healthcheck.timeout: 300. Check that your app's health endpoint responds within the Docker healthcheck interval.