Skip to main content

CI/CD & Blue/Green Deployment โ€” Frontpics Image Marketplace


Overviewโ€‹

Developer pushes to main
โ”‚
โ–ผ
GitHub Actions: CI pipeline
โ”œโ”€โ”€ Type check (tsc)
โ”œโ”€โ”€ Lint (ESLint)
โ”œโ”€โ”€ Unit tests (Vitest)
โ””โ”€โ”€ Build all apps (Turborepo)
โ”‚
โ–ผ (CI passes)
GitHub Actions: Deploy pipeline
โ”œโ”€โ”€ Build Docker images (api, web, worker)
โ”œโ”€โ”€ Push to GHCR (tagged :latest + :sha)
โ”œโ”€โ”€ SSH into VPS
โ”œโ”€โ”€ Pull new images
โ”œโ”€โ”€ Start inactive stack (blue or green)
โ”œโ”€โ”€ Health check inactive stack
โ”œโ”€โ”€ Switch Nginx to inactive stack
โ””โ”€โ”€ (Old stack kept running for 10 min for instant rollback)

Docker Setupโ€‹

Per-App Dockerfilesโ€‹

infrastructure/docker/api.Dockerfile

FROM node:26-alpine AS base
RUN npm install -g pnpm

# Install dependencies
FROM base AS deps
WORKDIR /app
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml ./
COPY packages/database/package.json ./packages/database/
COPY packages/shared/package.json ./packages/shared/
COPY packages/config/package.json ./packages/config/
COPY apps/api/package.json ./apps/api/
RUN pnpm install --frozen-lockfile

# Build
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/packages ./packages
COPY --from=deps /app/apps/api ./apps/api
COPY turbo.json tsconfig.base.json ./
RUN pnpm turbo build --filter=api...

# Production image
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/apps/api/dist ./dist
COPY --from=builder /app/packages/database ./packages/database
COPY --from=builder /app/node_modules ./node_modules
COPY apps/api/package.json ./
EXPOSE 4000
CMD ["node", "dist/main.js"]

infrastructure/docker/web.Dockerfile โ€” same pattern, EXPOSE 3000, Next.js standalone output.

infrastructure/docker/worker.Dockerfile โ€” same pattern, CMD ["node", "dist/main.js"], no port exposed.


Docker Compose Filesโ€‹

docker-compose.yml โ€” Local Developmentโ€‹

services:
postgres:
image: postgis/postgis:18-3.6-alpine
environment:
POSTGRES_USER: frontpics
POSTGRES_PASSWORD: frontpics_dev_password
POSTGRES_DB: frontpics
ports: ['5432:5432']
volumes: [postgres18_data:/var/lib/postgresql]

redis:
image: redis:8-alpine
ports: ['6379:6379']
command: redis-server --appendonly yes
volumes: [redis_data:/data]

api:
build:
context: .
dockerfile: infrastructure/docker/api.Dockerfile
target: deps # Use deps stage for hot reload in dev
command: pnpm --filter=api dev
volumes: [./apps/api/src:/app/apps/api/src]
ports: ['4000:4000']
env_file: .env
depends_on: [postgres, redis]

web:
build:
context: .
dockerfile: infrastructure/docker/web.Dockerfile
target: deps
command: pnpm --filter=web dev
volumes: [./apps/web/src:/app/apps/web/src]
ports: ['3000:3000']
env_file: .env
depends_on: [api]

worker:
build:
context: .
dockerfile: infrastructure/docker/worker.Dockerfile
target: deps
command: pnpm --filter=worker dev
volumes: [./apps/worker/src:/app/apps/worker/src]
env_file: .env
depends_on: [postgres, redis]

volumes:
postgres18_data:
redis_data:

docker-compose.blue.yml โ€” Production Blue Stack (port set 3000/4000)โ€‹

services:
api-blue:
image: ghcr.io/frontpics-app/api:${IMAGE_TAG:-latest}
container_name: frontpics-api-blue
restart: unless-stopped
ports: ['127.0.0.1:4000:4000']
env_file: /opt/frontpics/blue.env
networks: [frontpics-net]

web-blue:
image: ghcr.io/frontpics-app/web:${IMAGE_TAG:-latest}
container_name: frontpics-web-blue
restart: unless-stopped
ports: ['127.0.0.1:3000:3000']
env_file: /opt/frontpics/blue.env
networks: [frontpics-net]

worker-blue:
image: ghcr.io/frontpics-app/worker:${IMAGE_TAG:-latest}
container_name: frontpics-worker-blue
restart: unless-stopped
env_file: /opt/frontpics/blue.env
networks: [frontpics-net]

networks:
frontpics-net:
external: true

docker-compose.green.yml โ€” identical, with -green suffix, ports 4001:4000 and 3001:3000.

PostgreSQL and Redis are shared between both stacks (not duplicated โ€” they run as separate persistent containers in a docker-compose.infra.yml).


Nginx Blue/Green Configurationโ€‹

/infrastructure/nginx/frontpics.confโ€‹

upstream frontpics_active {
# Managed by deployment script โ€” points to blue or green
include /etc/nginx/conf.d/active-upstream.conf;
}

server {
listen 80;
server_name frontpics.app www.frontpics.app;
return 301 https://$host$request_uri;
}

server {
listen 443 ssl;
server_name frontpics.app www.frontpics.app;

ssl_certificate /etc/letsencrypt/live/frontpics.app/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/frontpics.app/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;

# Security headers
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "default-src 'self'; img-src 'self' cdn.frontpics.app; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';" always;

# API routes
location /api/ {
proxy_pass http://frontpics_active;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 30s;
}

# All other routes โ†’ Next.js
location / {
proxy_pass http://frontpics_active;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

/etc/nginx/conf.d/active-upstream.conf (managed by deploy script)โ€‹

# Blue is active
server 127.0.0.1:3000; # web

When switching to green, the deploy script atomically replaces this file and reloads Nginx.


GitHub Actions Workflowsโ€‹

.github/workflows/ci.yml โ€” Pull Request Checksโ€‹

name: CI

on:
pull_request:
branches: [main]

jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v4
with:
version: 9

- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Generate Prisma client
run: pnpm --filter @frontpics/database db:generate
env:
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/frontpics_test'

- name: Type check
run: pnpm turbo type-check

- name: Lint
run: pnpm turbo lint

- name: Run tests
run: pnpm turbo test
env:
CI: true

.github/workflows/deploy.yml โ€” Deploy on Merge to Mainโ€‹

name: Deploy

on:
push:
branches: [main]

env:
REGISTRY: ghcr.io
IMAGE_PREFIX: ghcr.io/${{ github.repository_owner }}/frontpics

jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write

steps:
- uses: actions/checkout@v4

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3

- name: Build & push API image
uses: docker/build-push-action@v5
with:
context: .
file: infrastructure/docker/api.Dockerfile
push: true
tags: |
${{ env.IMAGE_PREFIX }}-api:latest
${{ env.IMAGE_PREFIX }}-api:${{ github.sha }}
cache-from: type=gha,scope=api
cache-to: type=gha,mode=max,scope=api

- name: Build & push Web image
uses: docker/build-push-action@v5
with:
context: .
file: infrastructure/docker/web.Dockerfile
push: true
tags: |
${{ env.IMAGE_PREFIX }}-web:latest
${{ env.IMAGE_PREFIX }}-web:${{ github.sha }}
cache-from: type=gha,scope=web
cache-to: type=gha,mode=max,scope=web

- name: Build & push Worker image
uses: docker/build-push-action@v5
with:
context: .
file: infrastructure/docker/worker.Dockerfile
push: true
tags: |
${{ env.IMAGE_PREFIX }}-worker:latest
${{ env.IMAGE_PREFIX }}-worker:${{ github.sha }}
cache-from: type=gha,scope=worker
cache-to: type=gha,mode=max,scope=worker

deploy:
runs-on: ubuntu-latest
needs: build-and-push
environment: production

steps:
- uses: actions/checkout@v4

- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
/opt/frontpics/scripts/deploy.sh ${{ github.sha }}

Deployment Scriptโ€‹

/opt/frontpics/scripts/deploy.sh (on the VPS)

#!/usr/bin/env bash
set -euo pipefail

IMAGE_TAG="${1:-latest}"
COMPOSE_DIR="/opt/frontpics"
ACTIVE_STACK_FILE="/opt/frontpics/.active-stack"

# Read current active stack (blue or green)
CURRENT=$(cat "$ACTIVE_STACK_FILE" 2>/dev/null || echo "green")
if [ "$CURRENT" = "blue" ]; then
NEXT="green"
NEXT_API_PORT=4001
NEXT_WEB_PORT=3001
else
NEXT="blue"
NEXT_API_PORT=4000
NEXT_WEB_PORT=3000
fi

echo "๐Ÿš€ Deploying $IMAGE_TAG โ†’ $NEXT stack"

# Pull new images
IMAGE_TAG="$IMAGE_TAG" docker compose \
-f "$COMPOSE_DIR/docker-compose.$NEXT.yml" pull

# Run database migrations (safe to run on every deploy โ€” idempotent)
docker run --rm \
--env-file "$COMPOSE_DIR/$NEXT.env" \
--network frontpics-net \
"ghcr.io/frontpics-app/frontpics-api:$IMAGE_TAG" \
node dist/migrate.js

# Start the new (inactive) stack
IMAGE_TAG="$IMAGE_TAG" docker compose \
-f "$COMPOSE_DIR/docker-compose.$NEXT.yml" up -d

# Health check โ€” retry up to 30 times with 2s delay
echo "โณ Waiting for $NEXT stack to become healthy..."
for i in $(seq 1 30); do
if curl -sf "http://127.0.0.1:$NEXT_API_PORT/api/v1/health" > /dev/null; then
echo "โœ… Health check passed on attempt $i"
break
fi
if [ "$i" -eq 30 ]; then
echo "โŒ Health check failed after 30 attempts โ€” aborting deploy"
IMAGE_TAG="$IMAGE_TAG" docker compose \
-f "$COMPOSE_DIR/docker-compose.$NEXT.yml" down
exit 1
fi
sleep 2
done

# Switch Nginx to the new stack
echo "server 127.0.0.1:$NEXT_WEB_PORT;" > /etc/nginx/conf.d/active-upstream.conf
nginx -t && nginx -s reload
echo "$NEXT" > "$ACTIVE_STACK_FILE"

echo "โœ… Traffic switched to $NEXT stack (port $NEXT_WEB_PORT)"
echo "โณ Keeping $CURRENT stack running for 10 minutes (rollback window)..."

# Schedule teardown of old stack after 10 minutes
(sleep 600 && \
docker compose \
-f "$COMPOSE_DIR/docker-compose.$CURRENT.yml" down \
&& echo "๐Ÿงน Old $CURRENT stack stopped") &

echo "๐ŸŽ‰ Deploy complete"

Rollback Scriptโ€‹

/opt/frontpics/scripts/rollback.sh โ€” instantly switches Nginx back to the previous stack:

#!/usr/bin/env bash
set -euo pipefail

ACTIVE=$(cat /opt/frontpics/.active-stack)
if [ "$ACTIVE" = "blue" ]; then
PREV="green"; PORT=3001
else
PREV="blue"; PORT=3000
fi

# Check previous stack is still running
if ! curl -sf "http://127.0.0.1:$PORT/api/v1/health" > /dev/null; then
echo "โŒ Previous $PREV stack is not running. Cannot rollback."
exit 1
fi

echo "โš ๏ธ Rolling back to $PREV stack (port $PORT)"
echo "server 127.0.0.1:$PORT;" > /etc/nginx/conf.d/active-upstream.conf
nginx -t && nginx -s reload
echo "$PREV" > /opt/frontpics/.active-stack
echo "โœ… Traffic switched back to $PREV"

Health Check Endpointโ€‹

// apps/api/src/modules/health/health.controller.ts
@Controller('health')
@Public()
export class HealthController {
@Get()
check() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
}

VPS Setup Summaryโ€‹

Required on VPS:

# Install Docker
curl -fsSL https://get.docker.com | sh
usermod -aG docker $USER

# Install Nginx + Certbot
apt install nginx certbot python3-certbot-nginx

# Create directory structure
mkdir -p /opt/frontpics/scripts
mkdir -p /etc/nginx/conf.d

# Create shared Docker network (used by both stacks + infra)
docker network create frontpics-net

# Start infrastructure (Postgres + Redis) โ€” runs once, never restarted by deploy
docker compose -f /opt/frontpics/docker-compose.infra.yml up -d

# Run initial migration
docker run --rm \
--env-file /opt/frontpics/blue.env \
--network frontpics-net \
ghcr.io/frontpics-app/frontpics-api:latest \
node dist/migrate.js

# Initialize to blue stack
echo "blue" > /opt/frontpics/.active-stack
echo "server 127.0.0.1:3000;" > /etc/nginx/conf.d/active-upstream.conf

# Get SSL cert
certbot --nginx -d frontpics.app -d www.frontpics.app

# Add GitHub Actions deploy user SSH key
echo "ssh-ed25519 AAAA..." >> ~/.ssh/authorized_keys

VPS Sizing Recommendation:

PhaseCPURAMStorageCost
MVP launch2 vCPU4 GB40 GB SSD~$20/mo (Hetzner CX22)
Growth (10k users)4 vCPU8 GB80 GB SSD~$40/mo
Scale (100k users)Managed DB (RDS) + larger computeโ€”โ€”Review at this point

Images are on R2 (unlimited, ~$0.015/GB/month), not on VPS disk. VPS disk only needs space for Docker images and logs.


Secrets Managementโ€‹

Because this repository uses dynamic deployments (development and production), you must define your VPS secrets inside GitHub Environments, rather than globally.

  1. Go to Settings > Environments in your GitHub repository.
  2. Create new environments named development and production.
  3. Inside each, configure the following Environment secrets:
  • VPS_HOST โ€” VPS IP address for that specific environment
  • VPS_USER โ€” SSH username (e.g. deploy)
  • VPS_SSH_KEY โ€” SSH private key (ed25519, printed by the local-bootstrap tool)

Environment files on VPS (/opt/frontpics/blue.env, /opt/frontpics/green.env):

DATABASE_URL=postgresql://frontpics:password@postgres:5432/frontpics_db
REDIS_URL=redis://redis:6379
JWT_PRIVATE_KEY=...
JWT_PUBLIC_KEY=...
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET_NAME=frontpics-images
R2_PUBLIC_URL=https://cdn.frontpics.app
SMTP_HOST=...
SMTP_USER=...
SMTP_PASS=...
FRONTEND_URL=https://frontpics.app

These files are never committed to Git. They are provisioned manually or via a configuration management tool (Ansible, for Phase 2).