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 computeReview 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.


Salaisuuksien hallinta (Secrets Management)

Koska tämä repositorio käyttää dynaamisia ympäristöjä (development ja production), sinun tulee määrittää VPS-salaisuudet GitHub Environments -asetuksissa eikä globaaleina repository-salaisuuksina.

  1. Mene GitHubissa: Settings > Environments.
  2. Luo uudet ympäristöt nimeltä development ja production.
  3. Määritä kumpaankin seuraavat Environment secrets -tiedot:
  • VPS_HOST — Ympäristön VPS IP-osoite
  • VPS_USER — SSH-käyttäjätunnus (esim. deploy)
  • VPS_SSH_KEY — SSH-yksityisavain (ed25519, tulostuu local-bootstrap -työkalusta)

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).