Deploy to Test¶
The test pipeline (deploy.yml) builds the backend and frontend images on every push to main, runs the shared quality gates, and ships the result to the test server at /opt/app-name. This page walks through the workflow job by job. For the trigger model and gating logic see the Pipeline Overview.
Triggers and concurrency¶
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Pull requests run the gates only; an actual deploy happens only on a push to main (or a manual workflow_dispatch). cancel-in-progress: true means a fresh push supersedes any in-flight run on the same ref.
Workflow-level environment¶
env:
REGISTRY: ghcr.io
IMAGE_BACKEND: <org>/app-name-backend
IMAGE_FRONTEND: <org>/app-name-frontend
DEPLOY_PATH: /opt/app-name
IMAGE_TAG: ${{ github.sha }}
Images are tagged with the commit SHA, so every test deploy is traceable to an exact commit.
The three gate jobs¶
| Job | Runs when | Effect |
|---|---|---|
quality-gate |
vars.SONAR_ENABLED == 'true' and actor is not dependabot[bot] on a PR |
Generates frontend + backend coverage, runs SonarCloud, then polls the SonarCloud API until it finds an analysis whose revision matches GITHUB_SHA and asserts the Quality Gate is OK. |
e2e-gate |
Not a Dependabot PR | Validates tests/e2e/.results.json: status passed, recorded commit reachable from HEAD, and no untested non-doc/non-config files changed since. |
security-audit |
Always | npm audit --audit-level=high in ./frontend. |
See SonarCloud for the gate's configuration and the Secrets Matrix for SONAR_TOKEN.
The deploy-test job¶
The deploy job only starts when all three gates pass and the trigger is a push/dispatch on main:
deploy-test:
runs-on: ubuntu-latest
environment: test
needs: [quality-gate, e2e-gate, security-audit]
if: |
!cancelled() &&
(needs.quality-gate.result == 'success' || needs.quality-gate.result == 'skipped') &&
needs.e2e-gate.result == 'success' &&
needs.security-audit.result == 'success' &&
(github.event_name == 'push' || github.event_name == 'workflow_dispatch') &&
github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
id-token: write
1. GHCR login and Buildx¶
- name: Log in to the Container registry
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
The job's GITHUB_TOKEN (with packages: write) authenticates pushes to GHCR. See Docker & GHCR.
2. Architecture check¶
- name: Verify Architecture Standards
run: node scripts/check-architecture.js
A fast structural gate that runs before any image is built — if layering rules are violated, nothing ships.
3. Build and push both images¶
Backend and frontend are built from their own contexts and tagged with both :latest and :${{ github.sha }}, each with its own gha cache scope:
- name: Build and push backend image
uses: docker/build-push-action@v7
with:
context: ./backend
push: true
load: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_BACKEND }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_BACKEND }}:${{ github.sha }}
cache-from: type=gha,scope=backend
cache-to: type=gha,mode=max,scope=backend
- name: Build and push frontend image
uses: docker/build-push-action@v7
with:
context: ./frontend
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_FRONTEND }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_FRONTEND }}:${{ github.sha }}
cache-from: type=gha,scope=frontend
cache-to: type=gha,mode=max,scope=frontend
Separate cache scopes
scope=backend and scope=frontend keep the two build caches from clobbering each other, so a frontend-only change still hits the backend cache.
4. Trivy scans¶
Three Trivy steps run against the freshly pushed images and the filesystem. All use severity: CRITICAL, ignore-unfixed: true, trivyignores: .trivyignore, and exit-code: 1 — a critical finding fails the deploy.
- name: Scan backend image for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
image-ref: '${{ env.REGISTRY }}/${{ env.IMAGE_BACKEND }}:${{ github.sha }}'
exit-code: '1'
severity: 'CRITICAL'
ignore-unfixed: true
scanners: 'vuln'
cache-dir: ${{ runner.temp }}/.trivycache
# ... identical step for the frontend image ...
- name: Scan dependencies for vulnerabilities
uses: aquasecurity/trivy-action@master
with:
scan-type: 'fs'
scan-ref: '.'
exit-code: '1'
severity: 'CRITICAL'
The Trivy DB is cached between runs via actions/cache.
5. SSH over the Cloudflare Tunnel¶
cloudflared is installed, then an SSH config is written that proxies the connection through Cloudflare Access using a service token:
- name: Configure SSH for Cloudflare Tunnel
env:
SSH_HOST: ${{ secrets.DEPLOY_HOST || vars.DEPLOY_HOST }}
CF_CLIENT_ID: ${{ secrets.CF_ACCESS_CLIENT_ID }}
CF_CLIENT_SECRET: ${{ secrets.CF_ACCESS_CLIENT_SECRET }}
SSH_PRIVATE_KEY: ${{ secrets.SSH_KEY || vars.SSH_KEY }}
run: |
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_rsa && chmod 600 ~/.ssh/id_rsa
cat > ~/.ssh/config << EOF
Host ${SSH_HOST}
ProxyCommand cloudflared access ssh --hostname %h --id "${CF_CLIENT_ID}" --secret "${CF_CLIENT_SECRET}"
User ubuntu
IdentityFile ~/.ssh/id_rsa
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
EOF
chmod 600 ~/.ssh/config
The step fails fast if any of the three secrets are missing. See Cloudflare Tunnel.
6. rsync deployment files¶
The remote directories are initialised, then nginx/, scripts/, and docker-compose.yml are synced to /opt/app-name (the server's .env is always excluded):
- name: Copy deployment files to server
run: |
rsync -avz --delete --exclude='.env' -e "ssh" nginx/ ubuntu@$HOST:${{ env.DEPLOY_PATH }}/nginx/
rsync -avz --delete --exclude='.env' -e "ssh" scripts/ ubuntu@$HOST:${{ env.DEPLOY_PATH }}/scripts/
rsync -avz --exclude='.env' -e "ssh" docker-compose.yml ubuntu@$HOST:${{ env.DEPLOY_PATH }}/
7. Remote deploy.sh¶
A .env.deploy file carrying the deploy-time variables is generated, the deploy script is written to the server, and then executed. The script injects env, logs in to GHCR, prunes, and runs the compose lifecycle:
#!/bin/bash
set -e
cd /opt/app-name
# .env must already exist on the server (runtime secrets)
[ -f .env ] || { echo "❌ .env missing"; exit 1; }
# Inject deploy-time variables, then remove the file
if [ -f .env.deploy ]; then
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in [A-Za-z0-9_]*=*) export "$line" ;; esac
done < .env.deploy
rm .env.deploy
fi
echo "$GITHUB_TOKEN" | docker login ghcr.io -u "$GITHUB_ACTOR" --password-stdin
docker image prune -f
# Disk-usage guard: aggressive cleanup above 80%
DISK_USAGE=$(df / --output=pcent | tail -1 | tr -dc '0-9')
[ "$DISK_USAGE" -gt 80 ] && docker system prune -af --volumes
docker compose pull --policy always
docker compose down || true
sleep 2
docker compose run --rm app alembic upgrade head
docker compose run --rm app npm run sync-manual
if ! docker compose up -d --wait --wait-timeout 60; then
echo "❌ up failed/timed out"; docker compose logs app | tail -n 50; exit 1
fi
docker image prune -f
docker system prune -f
The .env file is never deployed
Runtime secrets live in /opt/app-name/.env on the server and are excluded from every rsync. The pipeline only injects the short-lived .env.deploy (image tag, GHCR token, actor, ENV), which is deleted after it is sourced. See Docker & GHCR.
Secrets and variables used¶
| Name | Kind | Purpose |
|---|---|---|
GITHUB_TOKEN |
Auto | GHCR push (CI) and login (server) |
SONAR_TOKEN |
Secret | SonarCloud auth |
DEPLOY_HOST |
Secret/var | Test server hostname |
SSH_KEY |
Secret/var | Deploy SSH private 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. When a health check fails, see Rollback & Incidents.