Dockflow

Deploying from CI

Deploy from GitHub Actions, GitLab CI, Jenkins, Buildkite or Bitbucket Pipelines, with the environment and version taken from the Git tag and secrets from CI variables.

The same dockflow binary runs on your laptop and on a CI runner. A typical setup builds the images on every branch and deploys when you push a tag.

In CI, Dockflow recognises the provider and reads the tag, branch and commit of the run. It takes its secrets from CI variables.

# .github/workflows/deploy.yml (the deploy job)
- run: curl -fsSL https://raw.githubusercontent.com/Shawiizz/dockflow/main/install.sh | sh -s -- --version v1.0.0-alpha.6
- run: dockflow deploy --wait-lock 10m
  env:
    DOCKFLOW_SECRETS: ${{ toJSON(secrets) }}

Pushing the tag 1.2.0 deploys version 1.2.0 to production. Pushing 1.2.0-staging deploys version 1.2.0 to staging. Complete workflows for each provider are at the end of this page.

What Dockflow reads from the CI provider

Dockflow checks the environment variables of the run in this order, and uses the first provider that matches:

ProviderDetected byTagBranchCommitActor
GitHub ActionsGITHUB_ACTIONS=trueGITHUB_REF_NAME when GITHUB_REF_TYPE is tagGITHUB_HEAD_REF, else GITHUB_REF_NAMEGITHUB_SHAGITHUB_ACTOR
GitLab CIGITLAB_CI=trueCI_COMMIT_TAGCI_COMMIT_BRANCHCI_COMMIT_SHAGITLAB_USER_LOGIN
BuildkiteBUILDKITE=trueBUILDKITE_TAGBUILDKITE_BRANCH, unless it equals the tagBUILDKITE_COMMITBUILDKITE_BUILD_CREATOR, else BUILDKITE_BUILD_AUTHOR
Bitbucket PipelinesBITBUCKET_BUILD_NUMBER is setBITBUCKET_TAGBITBUCKET_BRANCHBITBUCKET_COMMITBITBUCKET_STEP_TRIGGERER_UUID
JenkinsJENKINS_URL is setTAG_NAMEBRANCH_NAME, else GIT_BRANCHGIT_COMMITBUILD_USER_ID
Any otherCI is set––––

What each value is used for:

  • The tag selects the environment and gives the release its version label. See below.
  • The branch and the commit fill DOCKFLOW_GIT_BRANCH and DOCKFLOW_GIT_SHA when the checkout cannot tell (CI checkouts are often detached or shallow). When git answers, its values win. Without a tag, the version label is the short commit.
  • The actor and the provider are recorded as the release's author, which dockflow releases shows. They also name the holder of the environment's lock while the run lasts. On Jenkins, BUILD_USER_ID comes from the Build User Vars plugin; without it, the author is the runner's system user.

Without a terminal, Dockflow never prompts, so deploy applies its plan without --yes.

Tags select the environment and the version

In a run started by a tag, dockflow deploy without an environment splits the tag at its last hyphen:

TagVersion labelEnvironment
1.2.01.2.0The environment whose tag suffix is empty: production by default
v1.2.0v1.2.0The same
1.2.0-staging1.2.0The environment whose tag suffix is staging
1.2.0-rc1.2.0The environment whose tag suffix is rc

A suffix that names no environment is an error (exit code 2): Dockflow never guesses.

The default suffix is empty for production, and the environment's own name for every other environment. Set ci.tag_suffix to change it:

dockflow.yml
version: 1
app: shop
environments:
  production: {}        # tags 1.2.0
  staging: {}           # tags 1.2.0-staging
  preview:
    ci:
      tag_suffix: rc    # tags 1.2.0-rc

A pre-release tag with a hyphen, such as 1.2.0-rc.1, is read the same way: its suffix is rc.1. When your tags don't follow this pattern, name the environment on the command line.

An environment named on the command line always wins over the tag. dockflow deploy staging in a tag build deploys to staging, and the version label still comes from the tag. --version <label> replaces the version label.

Secrets as CI variables

Every secret of an environment is a CI variable prefixed with the environment's name in upper case. For production:

CI variableWhat it holds
PRODUCTION_CONNECTIONThe connection string of the server: its address, its host key and the deploy key. dockflow server init <admin>@<host> prints it when it prepares the server; dockflow server connection <user@host[:port]> -k <key> prints one for an existing user and key
PRODUCTION_DB_PASSWORDThe value of ${DB_PASSWORD} in your Compose files and dockflow.yml, for production

Give the connection string as this variable, not as --connection "$PRODUCTION_CONNECTION": an argument is visible in the process list to every user of the runner, which matters on shared or self-hosted runners. When a step must pass it explicitly, use --connection @<file> or --connection - with the string on standard input.

Among the process variables, Dockflow reads only those that start with the environment's prefix. A CI variable named DB_PASSWORD alone is ignored, so a variable meant for staging never reaches production by accident.

You can also pass a set of secrets as one JSON object in DOCKFLOW_SECRETS. On GitHub Actions, this gives Dockflow every secret of the repository:

env:
  DOCKFLOW_SECRETS: ${{ toJSON(secrets) }}

GitHub may hold the first run of a workflow that hands every secret to a job this way, until someone approves it on the run's page in the Actions tab. To avoid that, name each secret instead: PRODUCTION_DB_PASSWORD: ${{ secrets.PRODUCTION_DB_PASSWORD }}.

Inside DOCKFLOW_SECRETS, the key X is looked up as PRODUCTION_X first, then as X. An unprefixed key there is a value shared by every environment.

Process variables and DOCKFLOW_SECRETS come before .env.dockflow, the file you keep out of Git for local deploys. Secret values are masked as *** in everything Dockflow prints or stores: events, build and hook output, errors. See Secrets for the rest.

dockflow build and dockflow deploy interpolate the whole Compose file. A build job therefore needs every variable the Compose files use, as the deploy job does. On GitHub, secrets are not given to workflows started from forks.

Overlapping runs: --wait-lock

In CI, two runs often overlap, such as a push to main and a tag pushed right after. Let the second one wait:

dockflow deploy --wait-lock 10m

Each environment has one lock. Without --wait-lock, a run that starts while another holds the lock fails at once with exit code 5. With it, the run waits up to that long for the other one to finish, and emits a lock.waiting event that names the holder.

A lock taken by hand with dockflow lock acquire (a maintenance window) is never waited for. The deploy fails with exit code 5 unless you pass --force.

Building on every branch

dockflow build builds the images of the services with build:, without deploying. It needs no server: it builds for the runner's platform (--platform changes it) and checks that every image builds.

dockflow build production

In CI, dockflow build takes the environment from the tag, as deploy does. On a branch, name the environment when dockflow.yml has several. --only web,worker builds some services only. See Building images for every option.

A runner that builds needs Docker with buildx (or Podman, with build.engine: podman). A runner that only deploys services with public images needs no Docker at all: Dockflow reaches the server's Docker over SSH.

Build in one job, deploy in another

With --push, dockflow build connects to the server and sends the images where a deploy would, built for the server's platform. A later job deploys them with --skip-build, without building again. Both jobs must check out the same commit: it gives the version label, and so the image tag.

dockflow build production --push        # job 1
dockflow deploy production --skip-build # job 2, same commit

To have a person review the plan before it is applied, save it in one job and apply it in another, after an approval step:

dockflow plan production --out plan.json   # job 1: keep plan.json as an artifact
dockflow deploy --plan plan.json           # job 2, once approved

The second job builds nothing and applies exactly the reviewed plan. If anything changed in between, on the server or in the project, it fails with plan.stale (exit code 6) and changes nothing.

Complete workflows

Each workflow builds on branches and deploys on tags, with the CLI pinned to a version. The GitHub and GitLab ones also back up on a schedule, as dockflow init --ci writes them.

Replace production with your environment. Add the secrets your Compose files use (PRODUCTION_DB_PASSWORD here) as CI variables.

Add PRODUCTION_CONNECTION and your other secrets under Settings → Secrets and variables → Actions.

.github/workflows/deploy.yml
name: Deploy

on:
  push:
    branches: ["**"]
    tags: ["*"]
  schedule:
    - cron: "17 3 * * *"
  workflow_dispatch:

jobs:
  build:
    if: github.event_name == 'push' && github.ref_type == 'branch'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dockflow
        run: curl -fsSL https://raw.githubusercontent.com/Shawiizz/dockflow/main/install.sh | sh -s -- --version v1.0.0-alpha.6
      - name: Build
        run: dockflow build production
        env:
          DOCKFLOW_SECRETS: ${{ toJSON(secrets) }}

  deploy:
    if: github.event_name == 'push' && github.ref_type == 'tag'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dockflow
        run: curl -fsSL https://raw.githubusercontent.com/Shawiizz/dockflow/main/install.sh | sh -s -- --version v1.0.0-alpha.6
      - name: Deploy
        run: dockflow deploy --wait-lock 10m
        env:
          DOCKFLOW_SECRETS: ${{ toJSON(secrets) }}

  # the services that declare x-dockflow.backup, then the server's state
  backup:
    if: github.event_name != 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dockflow
        run: curl -fsSL https://raw.githubusercontent.com/Shawiizz/dockflow/main/install.sh | sh -s -- --version v1.0.0-alpha.6
      - name: Back up
        run: dockflow backup run production --wait-lock 10m && dockflow backup run production --server
        env:
          DOCKFLOW_SECRETS: ${{ toJSON(secrets) }}

schedule runs the backup job every night at 03:17 UTC, and workflow_dispatch lets you run it from the Actions tab.

The installer adds its directory to GITHUB_PATH when it is not already on the PATH, so later steps find dockflow.

The GitHub Action

On GitHub, the Shawiizz/dockflow action installs the CLI and runs it:

  • On a pull request, it posts the plan of the change as a comment, so a reviewer sees what merging would change on the server. The comment is updated on each push.
  • Elsewhere, it deploys.
.github/workflows/deploy.yml
name: Deploy

on:
  pull_request:
  push:
    tags: ["*"]

permissions:
  contents: read
  pull-requests: write # the plan's comment

jobs:
  dockflow:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: Shawiizz/[email protected]
        with:
          env: production # the environment a pull request's plan is for
          command: ${{ github.event_name == 'push' && 'deploy --wait-lock 10m' || '' }}
        env:
          DOCKFLOW_SECRETS: ${{ toJSON(secrets) }}

The comment looks like this:

Dockflow plan: shop to production

Version 4f2c1a9, server main.

ChangeTargetStrategyWhy
~service.updatewebstart-firstimage changed
upload.apply/etc/nginx/sites-enabled/shop.confchanged
InputDefaultDescription
commandplan on a pull request, deploy otherwiseThe command and its arguments, as deploy staging or backup run.
envnoneThe environment a pull request's plan is for. Needed when dockflow.yml has several.
commenttrueOn a pull request, post the plan as a comment. It is also written to the job's summary.
buildfalseOn a pull request, build the images, so the plan says exactly which services change. Off, the pull request's code is not built, and built services show as changed.
versionthe action's ref when it is a version, else the newest releaseThe Dockflow version to install; none installs nothing and uses the dockflow on the PATH.
working-directory.The project's directory.
github-tokengithub.tokenThe token that posts the comment. It needs pull-requests: write.

The action installs cosign first, then the CLI with install.sh --require-signature: the binary is checked against the release's SHA256SUMS, and SHA256SUMS against its Sigstore signature from the repository's release workflow. If either does not verify, the step fails and nothing runs. With version: none, it installs neither.

The plan needs the connection secret to read the server's state. GitHub gives no secrets to pull requests from forks: their plan fails, and the comment says so. A failed plan fails the job.

The plan gives pull requests the production connection

The connection secret is the deploy user's key: it can deploy, not only read. A pull request's run from a branch of the repository gets it, and that branch can change the workflow. With the plan on pull requests, anyone who can push a branch can reach production.

Turn it on only where everyone who pushes branches may deploy anyway, as in a team that reviews changes to its Compose files and dockflow.yml. Elsewhere, leave pull_request out of the workflow's triggers, or keep the secrets in a GitHub environment whose required reviewers approve each run.

The action computes the plan again at deploy time. After a merge, the commit (and maybe the server) changed since the review, so the action deploys the merged code, not the reviewed plan. To apply exactly a reviewed plan, use dockflow plan --out and dockflow deploy --plan, as in Build in one job, deploy in another.

Deploying from a branch

Tags are not required. To deploy every push to main to staging, name the environment:

dockflow deploy staging --wait-lock 10m

The version label is then the short commit, such as 3f9c2ab.

Exit codes

Every failure has its own exit code, so a CI step can tell a refused configuration (3) from a held lock (5), a failed build (7) or a deploy that was rolled back (8). See Exit codes.

Edit on GitHub

On this page