Skip to content

Deploy to Production

The production pipeline (deploy-production.yml) is triggered by pushing a version tag. It runs the same quality gates as test, builds a single application image tagged with the release version, and deploys it to the production server at /opt/app-name-prod under the protected production GitHub Environment. This page focuses on what differs from Deploy to Test.

Trigger: version tags

on:
  push:
    tags:
      - "v*"
  workflow_dispatch:

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: <org>/app-name
  DEPLOY_PATH: /opt/app-name-prod
  IMAGE_TAG: ${{ github.ref_name }}

Pushing any tag matching v* (for example v1.4.0) starts a production deploy. The image tag is github.ref_name — the literal tag — so the deployed version and the registry tag always match.

How production differs from test

Aspect Test (deploy.yml) Production (deploy-production.yml)
Trigger Push/PR to main Push a v* tag
Image tag :${{ github.sha }} :${{ github.ref_name }} (the version tag)
Images built Two: app-name-backend + app-name-frontend One: app-name (single context .)
GitHub Environment test production
Deploy path /opt/app-name /opt/app-name-prod
Host secret DEPLOY_HOST PROD_DEPLOY_HOST
SSH key secret SSH_KEY PROD_SSH_KEY (falls back to SSH_KEY)
SonarCloud gate name quality-gate sonar (sets projectVersion from package.json)

Single image, two shapes

The production workflow as shipped builds one image from the repository root and tags it ghcr.io/<org>/app-name:latest and ghcr.io/<org>/app-name:<tag>. The test workflow builds two images (-backend, -frontend). Both shapes are valid; pick the one that matches your repo layout and keep docker-compose.yml consistent with it. See Docker & GHCR.

Build step

- name: Build and push Docker image
  uses: docker/build-push-action@v7
  with:
    context: .
    push: true
    load: true
    cache-from: type=gha
    cache-to: type=gha,mode=max
    tags: |
      ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
      ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}

Because there is a single image, there is a single gha cache scope (no scope= qualifier). Trivy then scans this image and the filesystem with the same CRITICAL / exit-code: 1 policy as test.

Deploy job and gating

deploy-production:
  runs-on: ubuntu-latest
  environment: production
  needs: [sonar, e2e-gate, security-audit]
  if: |
    !cancelled() &&
    (needs.sonar.result == 'success' || needs.sonar.result == 'skipped') &&
    needs.e2e-gate.result == 'success' &&
    needs.security-audit.result == 'success'

There is no github.ref == 'refs/heads/main' clause here — the v* tag trigger already constrains when the workflow runs.

Require reviewers on production

Because the deploy job targets environment: production, you can attach required reviewers to that environment in GitHub settings. The deploy job then pauses for manual approval after the gates pass, giving you a human checkpoint before the release goes live.

Remote deploy flow

The SSH-over-Cloudflare setup, the rsync of nginx/, scripts/, and docker-compose.yml, and the remote deploy.sh are identical to test — env injection, GHCR login, prune, disk-usage guard, docker compose pull --policy always, down, alembic upgrade head, sync-manual, up -d --wait, health check, and cleanup. The only differences are the deploy path (/opt/app-name-prod), the PROD_* host/key secrets, and ENV=production. See Deploy to Test for the annotated script.

Cutting a release

Production deploys are driven entirely by tags, so cutting a release is a tag-and-push:

# Make sure main is green and you are on the commit you want to ship
git checkout main && git pull

# Tag and push
git tag v1.4.0
git push origin v1.4.0
# or push all tags
git push --tags

Pushing the tag starts deploy-production.yml. The gates run against the tagged commit, the image is built as ghcr.io/<org>/app-name:v1.4.0, and it deploys to /opt/app-name-prod.

Version the package too

The sonar job reads the version from package.json for sonar.projectVersion. Keep the package.json version and the git tag in step so SonarCloud history lines up with releases. See Version Control for the tagging convention.

Secrets and variables used

Name Kind Purpose
GITHUB_TOKEN Auto GHCR push (CI) and login (server)
SONAR_TOKEN Secret SonarCloud auth
PROD_DEPLOY_HOST Secret/var Production server hostname
PROD_SSH_KEY Secret Production SSH key (falls back to SSH_KEY)
CF_ACCESS_CLIENT_ID / CF_ACCESS_CLIENT_SECRET Secret Cloudflare Access service token
SONAR_ENABLED Variable Toggles the SonarCloud gate

Full details in the Secrets Matrix. If a release goes bad, see Rollback & Incidents.