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:
| Phase | CPU | RAM | Storage | Cost |
|---|---|---|---|---|
| MVP launch | 2 vCPU | 4 GB | 40 GB SSD | ~$20/mo (Hetzner CX22) |
| Growth (10k users) | 4 vCPU | 8 GB | 80 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.
- Go to Settings > Environments in your GitHub repository.
- Create new environments named
developmentandproduction. - Inside each, configure the following Environment secrets:
VPS_HOSTโ VPS IP address for that specific environmentVPS_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).