Skip to content

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.