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.