AI Prompt: Forma3D.Connect — Phase 1c: Staging Deployment ⏳¶
Purpose: This prompt instructs an AI to implement Phase 1c of Forma3D.Connect
Estimated Effort: 12 hours
Prerequisites: Phase 1b completed (Sentry observability, structured logging)
Output: Fully automated staging deployment on DigitalOcean with Traefik and Docker Compose
Status: ✅ COMPLETE
🎯 Mission¶
You are continuing development of Forma3D.Connect, building on the Phase 1b foundation. Your task is to implement Phase 1c: Staging Deployment — establishing automated deployment to a staging environment on DigitalOcean with zero-downtime deployments.
Phase 1c delivers:
- Automated Docker image building and tagging
- Push to DigitalOcean Container Registry
- Deployment via SSH + Docker Compose
- Traefik reverse proxy with Let's Encrypt TLS
- Prisma database migration handling
📋 Phase 1c Context¶
What Was Built in Phase 0, 1 & 1b¶
The foundation is already in place:
- Nx monorepo with
apps/api,apps/web, and shared libs - PostgreSQL database with Prisma schema
- NestJS backend with Shopify webhooks, order storage, product mappings
- React 19 frontend with basic dashboard
- Azure DevOps CI/CD pipeline (validate, test, build stages)
- OpenAPI/Swagger documentation at
/api/docs - Aikido Security Platform for vulnerability scanning
- Sentry observability with error tracking and performance monitoring
- Structured JSON logging with correlation IDs
What Phase 1c Builds¶
| Feature | Description | Effort |
|---|---|---|
| F1c.1: Docker Image Build | Build and tag Docker images in Azure Pipeline | 3 hours |
| F1c.2: Container Registry Push | Push images to DigitalOcean Container Registry | 2 hours |
| F1c.3: Staging Deployment | Deploy via SSH + Docker Compose | 4 hours |
| F1c.4: Traefik Configuration | Reverse proxy with TLS and routing | 3 hours |
🛠️ Tech Stack Reference¶
All technologies from Phase 1b remain. Additional infrastructure for Phase 1c:
| Technology | Purpose |
|---|---|
| Docker | Container runtime |
| Docker Compose | Multi-container orchestration |
| Traefik | Reverse proxy with automatic TLS |
| Let's Encrypt | Free TLS certificates |
| DigitalOcean Droplet | Staging server |
| DigitalOcean Managed PostgreSQL | Staging database |
| DigitalOcean Container Registry | Private Docker registry |
🏗️ Infrastructure Reference¶
Droplet Information¶
- Public IP:
167.172.45.47 - SSH access: via SSH key pair (see
/Users/jeankedotcom/Library/CloudStorage/OneDrive-Personal/DevGem/projects/forma-3d-connect/sec/droplet/azure-devopsand/Users/jeankedotcom/Library/CloudStorage/OneDrive-Personal/DevGem/projects/forma-3d-connect/sec/droplet/azure-devops.pub)
Database Information¶
- Connection details: see
/Users/jeankedotcom/Library/CloudStorage/OneDrive-Personal/DevGem/projects/forma-3d-connect/sec/managed-postgresql/db-info.txt - TLS CA Certificate: see
/Users/jeankedotcom/Library/CloudStorage/OneDrive-Personal/DevGem/projects/forma-3d-connect/sec/managed-postgresql/do-postgresql-ca-certificate-2.crt
Container Registry¶
- Registry URL: see
/Users/jeankedotcom/Library/CloudStorage/OneDrive-Personal/DevGem/projects/forma-3d-connect/sec/container-registry/url.txt - Docker config: see
/Users/jeankedotcom/Library/CloudStorage/OneDrive-Personal/DevGem/projects/forma-3d-connect/sec/container-registry/docker-config.json
Staging URLs (DNS pre-configured)¶
- API:
https://staging-connect-api.forma3d.be - Web:
https://staging-connect.forma3d.be
📁 Files to Create/Modify¶
azure-pipelines.yml # Extended with package, publish, deploy stages
apps/api/
├── Dockerfile # API Docker image
apps/web/
├── Dockerfile # Web Docker image
├── nginx.conf # Nginx configuration for SPA
deployment/
├── staging/
│ ├── docker-compose.yml # Staging stack definition
│ ├── traefik.yml # Traefik static configuration
│ ├── .env.staging.template # Environment template
│ └── deploy.sh # Deployment script
🔧 Feature F1c.1: Docker Image Build¶
Requirements Reference¶
- NFR-DE-001: Containerization
- NFR-DE-002: Image Tagging
Implementation¶
1. API Dockerfile¶
Create apps/api/Dockerfile:
# ============================================================================
# Forma3D.Connect API - Production Dockerfile
# ============================================================================
# Multi-stage build for optimized production image
# ============================================================================
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9 --activate
# Copy package files
COPY package.json pnpm-lock.yaml ./
COPY prisma ./prisma/
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source
COPY . .
# Generate Prisma client
RUN pnpm prisma generate
# Build the API
RUN pnpm nx build api --prod
# Stage 2: Production
FROM node:20-alpine AS production
WORKDIR /app
# Install pnpm for Prisma CLI
RUN corepack enable && corepack prepare pnpm@9 --activate
# Create non-root user
RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nestjs
# Copy built application
COPY --from=builder /app/dist/apps/api ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/package.json ./
# Set ownership
RUN chown -R nestjs:nodejs /app
USER nestjs
# Expose port
EXPOSE 3000
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
# Start the application
CMD ["node", "dist/main.js"]
2. Web Dockerfile¶
Create apps/web/Dockerfile:
# ============================================================================
# Forma3D.Connect Web - Production Dockerfile
# ============================================================================
# Multi-stage build with Nginx for static serving
# ============================================================================
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Install pnpm
RUN corepack enable && corepack prepare pnpm@9 --activate
# Copy package files
COPY package.json pnpm-lock.yaml ./
# Install dependencies
RUN pnpm install --frozen-lockfile
# Copy source
COPY . .
# Build the Web app
RUN pnpm nx build web --prod
# Stage 2: Production
FROM nginx:alpine AS production
# Copy custom nginx config
COPY apps/web/nginx.conf /etc/nginx/nginx.conf
# Copy built application
COPY --from=builder /app/dist/apps/web /usr/share/nginx/html
# Create non-root user
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
chown -R nginx:nginx /etc/nginx/conf.d && \
touch /var/run/nginx.pid && \
chown -R nginx:nginx /var/run/nginx.pid
USER nginx
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]
3. Nginx Configuration for SPA¶
Create apps/web/nginx.conf:
# ============================================================================
# Forma3D.Connect Web - Nginx Configuration
# ============================================================================
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript
application/rss+xml application/atom+xml image/svg+xml;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Don't cache index.html
location = /index.html {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# Health check endpoint
location /health {
return 200 'OK';
add_header Content-Type text/plain;
}
}
}
🔧 Feature F1c.2: Container Registry Push¶
Requirements Reference¶
- NFR-DE-003: Registry Integration
- NFR-DE-004: Image Versioning
Implementation¶
Docker Image Labels¶
All images must include standard labels for traceability:
| Label | Description |
|---|---|
org.label-schema.schema-version |
Schema version |
org.label-schema.name |
Application name |
org.label-schema.description |
Short description |
org.label-schema.vcs-url |
Git repository URL |
org.label-schema.vcs-ref |
Git commit hash |
org.label-schema.version |
Pipeline instance name |
org.label-schema.build-date |
Build timestamp |
org.label-schema.vendor |
Organization |
🔧 Feature F1c.3: Staging Deployment¶
Requirements Reference¶
- NFR-DE-005: Automated Deployment
- NFR-DE-006: Idempotent Deployments
- NFR-RE-004: Database Migrations
Implementation¶
1. Docker Compose (Staging)¶
Create deployment/staging/docker-compose.yml:
# ============================================================================
# Forma3D.Connect - Staging Docker Compose
# ============================================================================
services:
# --------------------------------------------------------------------------
# Traefik Reverse Proxy
# --------------------------------------------------------------------------
traefik:
image: traefik:v3.0
container_name: forma3d-traefik
restart: unless-stopped
ports:
- '80:80'
- '443:443'
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- traefik-certs:/letsencrypt
networks:
- forma3d-network
labels:
- 'traefik.enable=true'
# Dashboard (optional, disabled by default)
# - "traefik.http.routers.dashboard.rule=Host(`traefik.staging-connect.forma3d.be`)"
# - "traefik.http.routers.dashboard.service=api@internal"
# --------------------------------------------------------------------------
# API Application
# --------------------------------------------------------------------------
api:
image: ${REGISTRY_URL}/forma3d-connect-api:${IMAGE_TAG:-latest}
container_name: forma3d-api
restart: unless-stopped
environment:
- NODE_ENV=staging
- APP_PORT=3000
- APP_URL=${API_URL}
- FRONTEND_URL=${WEB_URL}
- DATABASE_URL=${DATABASE_URL}
- SHOPIFY_SHOP_DOMAIN=${SHOPIFY_SHOP_DOMAIN}
- SHOPIFY_API_KEY=${SHOPIFY_API_KEY}
- SHOPIFY_API_SECRET=${SHOPIFY_API_SECRET}
- SHOPIFY_ACCESS_TOKEN=${SHOPIFY_ACCESS_TOKEN}
- SHOPIFY_WEBHOOK_SECRET=${SHOPIFY_WEBHOOK_SECRET}
- SHOPIFY_API_VERSION=${SHOPIFY_API_VERSION}
- SENTRY_DSN=${SENTRY_DSN}
- SENTRY_ENVIRONMENT=staging
volumes:
- ./certs/do-postgresql-ca-certificate-2.crt:/app/certs/ca-certificate.crt:ro
networks:
- forma3d-network
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.api.rule=Host(`staging-connect-api.forma3d.be`)'
- 'traefik.http.routers.api.entrypoints=websecure'
- 'traefik.http.routers.api.tls=true'
- 'traefik.http.routers.api.tls.certresolver=letsencrypt'
- 'traefik.http.services.api.loadbalancer.server.port=3000'
# Health check
- 'traefik.http.services.api.loadbalancer.healthcheck.path=/health'
- 'traefik.http.services.api.loadbalancer.healthcheck.interval=30s'
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:3000/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
depends_on:
- traefik
# --------------------------------------------------------------------------
# Web Application
# --------------------------------------------------------------------------
web:
image: ${REGISTRY_URL}/forma3d-connect-web:${IMAGE_TAG:-latest}
container_name: forma3d-web
restart: unless-stopped
networks:
- forma3d-network
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.web.rule=Host(`staging-connect.forma3d.be`)'
- 'traefik.http.routers.web.entrypoints=websecure'
- 'traefik.http.routers.web.tls=true'
- 'traefik.http.routers.web.tls.certresolver=letsencrypt'
- 'traefik.http.services.web.loadbalancer.server.port=80'
healthcheck:
test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:80/health']
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
depends_on:
- api
networks:
forma3d-network:
driver: bridge
volumes:
traefik-certs:
2. Traefik Configuration¶
Create deployment/staging/traefik.yml:
# ============================================================================
# Forma3D.Connect - Traefik Configuration
# ============================================================================
# API and Dashboard
api:
dashboard: false # Disable dashboard in staging
# Entry Points
entryPoints:
web:
address: ':80'
http:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: true
websecure:
address: ':443'
# Certificate Resolvers
certificatesResolvers:
letsencrypt:
acme:
email: admin@forma3d.be
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: web
# Providers
providers:
docker:
endpoint: 'unix:///var/run/docker.sock'
exposedByDefault: false
network: forma3d-network
# Logging
log:
level: INFO
format: json
accessLog:
format: json
fields:
defaultMode: keep
headers:
defaultMode: drop
names:
User-Agent: keep
X-Forwarded-For: keep
3. Deployment Script¶
Create deployment/staging/deploy.sh:
#!/bin/bash
# ============================================================================
# Forma3D.Connect - Staging Deployment Script
# ============================================================================
# Usage: ./deploy.sh <image_tag>
# ============================================================================
set -euo pipefail
# Configuration
IMAGE_TAG="${1:-latest}"
COMPOSE_FILE="docker-compose.yml"
echo "============================================"
echo "Forma3D.Connect - Staging Deployment"
echo "Image Tag: ${IMAGE_TAG}"
echo "============================================"
# Step 1: Pull latest images
echo "[1/5] Pulling Docker images..."
docker compose pull
# Step 2: Run database migrations
echo "[2/5] Running database migrations..."
docker compose run --rm api npx prisma migrate deploy
# Step 3: Start services with zero-downtime
echo "[3/5] Starting services..."
docker compose up -d --remove-orphans
# Step 4: Wait for health checks
echo "[4/5] Waiting for services to be healthy..."
sleep 10
# Step 5: Verify deployment
echo "[5/5] Verifying deployment..."
API_HEALTH=$(curl -s -o /dev/null -w "%{http_code}" https://staging-connect-api.forma3d.be/health || echo "000")
WEB_HEALTH=$(curl -s -o /dev/null -w "%{http_code}" https://staging-connect.forma3d.be/ || echo "000")
if [ "$API_HEALTH" == "200" ] && [ "$WEB_HEALTH" == "200" ]; then
echo "============================================"
echo "✅ Deployment successful!"
echo " API: https://staging-connect-api.forma3d.be"
echo " Web: https://staging-connect.forma3d.be"
echo "============================================"
else
echo "============================================"
echo "❌ Deployment verification failed!"
echo " API health: $API_HEALTH"
echo " Web health: $WEB_HEALTH"
echo "============================================"
exit 1
fi
# Clean up old images
echo "Cleaning up old images..."
docker image prune -f
🔧 Feature F1c.4: Azure Pipeline Extension¶
Requirements Reference¶
- NFR-DE-007: CI/CD Integration
- NFR-DE-008: Pipeline Instance Naming
Implementation¶
Updated Azure Pipeline¶
Update azure-pipelines.yml with the following additions:
# ============================================================================
# Forma3D.Connect - Azure DevOps CI/CD Pipeline
# ============================================================================
# Extended with Docker build, push, and staging deployment
# ============================================================================
trigger:
branches:
include:
- main
- develop
paths:
exclude:
- '*.md'
- 'docs/**'
pr:
branches:
include:
- main
- develop
# Pipeline instance naming: YYYYMMDDhhmmss
name: $(Date:yyyyMMddHHmmss)
variables:
nodeVersion: '20.x'
pnpmVersion: '9'
isMain: $[eq(variables['Build.SourceBranch'], 'refs/heads/main')]
isDevelop: $[eq(variables['Build.SourceBranch'], 'refs/heads/develop')]
# Docker variables
dockerRegistry: 'registry.digitalocean.com/forma3d'
apiImageName: 'forma3d-connect-api'
webImageName: 'forma3d-connect-web'
imageTag: $(Build.BuildNumber)
pool:
vmImage: 'ubuntu-latest'
stages:
# --------------------------------------------------------------------------
# Stage: Validate
# --------------------------------------------------------------------------
- stage: Validate
displayName: 'Validate'
jobs:
- job: Lint
displayName: 'Lint'
steps:
- task: NodeTool@0
displayName: 'Install Node.js'
inputs:
versionSpec: '$(nodeVersion)'
- script: |
corepack enable
corepack prepare pnpm@$(pnpmVersion) --activate
displayName: 'Install pnpm'
- script: pnpm install --frozen-lockfile
displayName: 'Install Dependencies'
- script: pnpm nx affected --target=lint --parallel=3
displayName: 'Run Linting'
- job: TypeCheck
displayName: 'Type Check'
steps:
- task: NodeTool@0
displayName: 'Install Node.js'
inputs:
versionSpec: '$(nodeVersion)'
- script: |
corepack enable
corepack prepare pnpm@$(pnpmVersion) --activate
displayName: 'Install pnpm'
- script: pnpm install --frozen-lockfile
displayName: 'Install Dependencies'
- script: pnpm prisma generate
displayName: 'Generate Prisma Client'
- script: pnpm nx run-many --target=typecheck --all --parallel=3
displayName: 'Run Type Check'
# --------------------------------------------------------------------------
# Stage: Test
# --------------------------------------------------------------------------
- stage: Test
displayName: 'Test'
dependsOn: Validate
jobs:
- job: UnitTests
displayName: 'Unit Tests'
services:
postgres:
image: postgres:16
ports:
- 5432:5432
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: forma3d_connect_test
steps:
- task: NodeTool@0
displayName: 'Install Node.js'
inputs:
versionSpec: '$(nodeVersion)'
- script: |
corepack enable
corepack prepare pnpm@$(pnpmVersion) --activate
displayName: 'Install pnpm'
- script: pnpm install --frozen-lockfile
displayName: 'Install Dependencies'
- script: pnpm prisma generate
displayName: 'Generate Prisma Client'
- script: |
for i in {1..30}; do
pg_isready -h localhost -p 5432 -U postgres && break
sleep 1
done
displayName: 'Wait for PostgreSQL'
- script: pnpm prisma migrate deploy
displayName: 'Run Migrations'
env:
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/forma3d_connect_test?schema=public'
- script: pnpm nx affected --target=test --parallel=3 --coverage
displayName: 'Run Unit Tests'
env:
DATABASE_URL: 'postgresql://postgres:postgres@localhost:5432/forma3d_connect_test?schema=public'
- task: PublishTestResults@2
displayName: 'Publish Test Results'
condition: succeededOrFailed()
inputs:
testResultsFormat: 'JUnit'
testResultsFiles: '**/junit.xml'
mergeTestResults: true
# --------------------------------------------------------------------------
# Stage: Build
# --------------------------------------------------------------------------
- stage: Build
displayName: 'Build'
dependsOn: Test
jobs:
- job: BuildAll
displayName: 'Build All Projects'
steps:
- task: NodeTool@0
displayName: 'Install Node.js'
inputs:
versionSpec: '$(nodeVersion)'
- script: |
corepack enable
corepack prepare pnpm@$(pnpmVersion) --activate
displayName: 'Install pnpm'
- script: pnpm install --frozen-lockfile
displayName: 'Install Dependencies'
- script: pnpm prisma generate
displayName: 'Generate Prisma Client'
- script: pnpm nx affected --target=build --parallel=3
displayName: 'Build Projects'
- task: PublishBuildArtifacts@1
displayName: 'Publish API Artifact'
inputs:
pathToPublish: 'dist/apps/api'
artifactName: 'api'
condition: and(succeeded(), or(eq(variables.isMain, true), eq(variables.isDevelop, true)))
- task: PublishBuildArtifacts@1
displayName: 'Publish Web Artifact'
inputs:
pathToPublish: 'dist/apps/web'
artifactName: 'web'
condition: and(succeeded(), or(eq(variables.isMain, true), eq(variables.isDevelop, true)))
# --------------------------------------------------------------------------
# Stage: Package (Docker Build & Push)
# --------------------------------------------------------------------------
- stage: Package
displayName: 'Package & Publish'
dependsOn: Build
condition: and(succeeded(), eq(variables.isDevelop, true))
jobs:
- job: BuildAndPushImages
displayName: 'Build & Push Docker Images'
steps:
- task: DownloadSecureFile@1
name: dockerConfig
displayName: 'Download Docker Config'
inputs:
secureFile: 'docker-config.json'
- script: |
mkdir -p ~/.docker
cp $(dockerConfig.secureFilePath) ~/.docker/config.json
displayName: 'Configure Docker Registry'
- script: |
docker build \
--file apps/api/Dockerfile \
--tag $(dockerRegistry)/$(apiImageName):$(imageTag) \
--tag $(dockerRegistry)/$(apiImageName):latest \
--label "org.label-schema.schema-version=1.0" \
--label "org.label-schema.name=$(apiImageName)" \
--label "org.label-schema.description=Forma3D.Connect API" \
--label "org.label-schema.vcs-url=$(Build.Repository.Uri)" \
--label "org.label-schema.vcs-ref=$(Build.SourceVersion)" \
--label "org.label-schema.version=$(imageTag)" \
--label "org.label-schema.build-date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "org.label-schema.vendor=Forma3D" \
--label "org.label-schema.vcs-branch=$(Build.SourceBranchName)" \
.
displayName: 'Build API Docker Image'
- script: |
docker build \
--file apps/web/Dockerfile \
--tag $(dockerRegistry)/$(webImageName):$(imageTag) \
--tag $(dockerRegistry)/$(webImageName):latest \
--label "org.label-schema.schema-version=1.0" \
--label "org.label-schema.name=$(webImageName)" \
--label "org.label-schema.description=Forma3D.Connect Web" \
--label "org.label-schema.vcs-url=$(Build.Repository.Uri)" \
--label "org.label-schema.vcs-ref=$(Build.SourceVersion)" \
--label "org.label-schema.version=$(imageTag)" \
--label "org.label-schema.build-date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
--label "org.label-schema.vendor=Forma3D" \
--label "org.label-schema.vcs-branch=$(Build.SourceBranchName)" \
.
displayName: 'Build Web Docker Image'
- script: |
docker push $(dockerRegistry)/$(apiImageName):$(imageTag)
docker push $(dockerRegistry)/$(apiImageName):latest
docker push $(dockerRegistry)/$(webImageName):$(imageTag)
docker push $(dockerRegistry)/$(webImageName):latest
displayName: 'Push Docker Images'
# --------------------------------------------------------------------------
# Stage: Deploy to Staging
# --------------------------------------------------------------------------
- stage: DeployStaging
displayName: 'Deploy to Staging'
dependsOn: Package
condition: and(succeeded(), eq(variables.isDevelop, true))
jobs:
- deployment: DeployStaging
displayName: 'Deploy to Staging'
environment: 'staging'
strategy:
runOnce:
deploy:
steps:
- task: DownloadSecureFile@1
name: sshKey
displayName: 'Download SSH Key'
inputs:
secureFile: 'azure-devops'
- task: DownloadSecureFile@1
name: dbCert
displayName: 'Download DB Certificate'
inputs:
secureFile: 'do-postgresql-ca-certificate-2.crt'
- script: |
chmod 600 $(sshKey.secureFilePath)
displayName: 'Set SSH Key Permissions'
- script: |
ssh -o StrictHostKeyChecking=no -i $(sshKey.secureFilePath) root@167.172.45.47 << 'EOF'
set -e
# Navigate to deployment directory
cd /opt/forma3d
# Update environment
export IMAGE_TAG=$(imageTag)
# Pull latest images
docker compose pull
# Run migrations
docker compose run --rm api npx prisma migrate deploy
# Deploy with zero-downtime
docker compose up -d --remove-orphans
# Clean up
docker image prune -f
echo "Deployment complete: $(imageTag)"
EOF
displayName: 'Deploy to Staging Server'
env:
IMAGE_TAG: $(imageTag)
# --------------------------------------------------------------------------
# Stage: Deploy to Production (Placeholder)
# --------------------------------------------------------------------------
- stage: DeployProduction
displayName: 'Deploy to Production'
dependsOn: Build
condition: and(succeeded(), eq(variables.isMain, true))
jobs:
- deployment: DeployProduction
displayName: 'Deploy to Production'
environment: 'production'
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploy to production environment (placeholder)"
displayName: 'Deploy Production (placeholder)'
🗄️ Database Migrations (CRITICAL)¶
Prisma Migration Strategy¶
The API uses Prisma. Database migrations are handled as follows:
- Migrations run BEFORE container starts — Executed in the deployment pipeline
- Command:
npx prisma migrate deploy— Safe, idempotent, production-ready - Failure behavior: Deployment fails if migrations fail (no partial deployments)
Migration Execution¶
# In deployment, run migrations before starting the new container
docker compose run --rm api npx prisma migrate deploy
Why This Approach?¶
- Explicit: Migrations run as a separate step, not hidden in startup
- Visible: Pipeline shows migration success/failure clearly
- Rollback-friendly: If migrations fail, old containers keep running
- Idempotent:
migrate deployonly runs pending migrations
🔐 Secrets & Credentials¶
Azure DevOps Configuration Required¶
Secure Files¶
Upload these files to Azure DevOps > Pipelines > Library > Secure files:
| File | Description |
|---|---|
azure-devops |
SSH private key for droplet |
docker-config.json |
DigitalOcean registry auth |
do-postgresql-ca-certificate-2.crt |
PostgreSQL TLS certificate |
Variable Groups¶
Create a variable group forma3d-staging with:
| Variable | Secret? | Description |
|---|---|---|
DROPLET_IP |
No | 167.172.45.47 |
DATABASE_URL |
Yes | Full PostgreSQL connection URL |
SHOPIFY_SHOP_DOMAIN |
No | Shopify store domain |
SHOPIFY_API_KEY |
Yes | Shopify API key |
SHOPIFY_API_SECRET |
Yes | Shopify API secret |
SHOPIFY_ACCESS_TOKEN |
Yes | Shopify access token |
SHOPIFY_WEBHOOK_SECRET |
Yes | Shopify webhook secret |
SHOPIFY_API_VERSION |
No | 2024-01 |
SENTRY_DSN |
Yes | Sentry DSN |
✅ Validation Checklist¶
Infrastructure¶
- Dockerfiles created for API and Web
- Docker images build successfully locally
- Images push to DigitalOcean Container Registry
- Docker Compose file validates
Traefik Configuration (F1c.4)¶
- Traefik starts and listens on 80/443
- HTTP redirects to HTTPS
- TLS certificates issued by Let's Encrypt
- API routes correctly to
staging-connect-api.forma3d.be - Web routes correctly to
staging-connect.forma3d.be
Deployment (F1c.3)¶
- Pipeline builds Docker images with correct tags
- Pipeline pushes images to registry
- SSH connection to droplet works
- Database migrations run successfully
- Containers start and pass health checks
- Application accessible at staging URLs
Database¶
- Prisma migrations run before container start
- TLS connection to managed PostgreSQL works
- CA certificate mounted correctly
🚫 Constraints and Rules¶
MUST DO¶
- Use the exact image tag format:
YYYYMMDDhhmmss(pipeline build number) - Run database migrations BEFORE starting new containers
- Use Docker labels for all images
- Use Traefik for routing (no Nginx in front)
- Use SSH + Docker Compose for deployment (no complex orchestration)
- Store all secrets in Azure DevOps Secure Files / Variable Groups
MUST NOT¶
- Inline secrets in pipeline YAML
- Use hardcoded IP addresses in code (use environment variables)
- Skip HMAC verification for webhooks
- Deploy without running migrations first
- Use
docker compose downduring deployments (causes downtime) - Modify production deployment stage (keep as placeholder)
🎬 Execution Order¶
- Create API Dockerfile in
apps/api/Dockerfile - Create Web Dockerfile in
apps/web/Dockerfile - Create Nginx config in
apps/web/nginx.conf - Create deployment directory
deployment/staging/ - Create Docker Compose for staging
- Create Traefik config
- Create deployment script
- Update Azure Pipeline with Package and Deploy stages
- Upload secrets to Azure DevOps
- Configure droplet with Docker and Docker Compose
- Run first deployment
- Verify staging URLs
- Run validation checklist
📊 Expected Output¶
When Phase 1c is complete:
Verification Commands¶
# Verify API is running
curl https://staging-connect-api.forma3d.be/health
# Expected: {"status":"ok","database":"connected","timestamp":"..."}
# Verify Web is running
curl -I https://staging-connect.forma3d.be
# Expected: HTTP/2 200
# Verify Swagger docs
curl https://staging-connect-api.forma3d.be/api/docs
# Expected: Swagger UI HTML
# Check container status on droplet
ssh root@167.172.45.47 "docker ps"
# Expected: traefik, api, web containers running
# Check image versions
ssh root@167.172.45.47 "docker images | grep forma3d"
# Expected: Images with correct tags
Pipeline Verification¶
- Push to
developbranch - Pipeline should run: Validate → Test → Build → Package → Deploy Staging
- Staging URLs should be accessible
- Images tagged with
YYYYMMDDhhmmssformat
🔗 Phase 1c Exit Criteria¶
- Docker images build and push successfully
- Pipeline instance naming follows
YYYYMMDDhhmmssformat - Docker labels applied to all images
- Traefik issues TLS certificates automatically
- Database migrations run before deployment
- Zero-downtime deployments working
- API accessible at
https://staging-connect-api.forma3d.be - Web accessible at
https://staging-connect.forma3d.be - Health checks pass for all services
- Deployment verification in pipeline
📝 Documentation Updates¶
README.md Updates Required¶
Update README.md with a new section for deployment configuration:
Add "Deployment Configuration (Azure DevOps)" Section¶
## Deployment Configuration (Azure DevOps)
This section describes how to configure the Azure DevOps pipeline for automated staging deployment.
### Prerequisites
Before the pipeline can deploy to staging, you must configure the following in Azure DevOps:
1. **Environments** — Create `staging` and `production` environments
2. **Secure Files** — Upload required certificates and keys
3. **Variable Groups** — Create variable group with secrets
### Step 1: Create Environments
1. Navigate to **Pipelines > Environments**
2. Create environment: `staging`
3. Create environment: `production`
4. (Optional) Add approval gates for production
### Step 2: Upload Secure Files
Navigate to **Pipelines > Library > Secure files** and upload:
| File | Description | Source |
| ------------------------------------ | --------------------------- | ------------------------------------------------ |
| `azure-devops` | SSH private key for droplet | `/Users/jeankedotcom/Library/CloudStorage/OneDrive-Personal/DevGem/projects/forma-3d-connect/sec/droplet/azure-devops` |
| `docker-config.json` | Docker registry auth | `/Users/jeankedotcom/Library/CloudStorage/OneDrive-Personal/DevGem/projects/forma-3d-connect/sec/container-registry/docker-config.json` |
| `do-postgresql-ca-certificate-2.crt` | PostgreSQL TLS cert | `/Users/jeankedotcom/Library/CloudStorage/OneDrive-Personal/DevGem/projects/forma-3d-connect/sec/managed-postgresql/` |
### Step 3: Create Variable Group
1. Navigate to **Pipelines > Library > Variable groups**
2. Create group: `forma3d-staging`
3. Add the following variables:
| Variable | Secret | Value |
| ------------------------ | ------ | ------------------------------------------------------------------------- |
| `DROPLET_IP` | No | `167.172.45.47` |
| `DATABASE_URL` | Yes | PostgreSQL connection URL (see `/Users/jeankedotcom/Library/CloudStorage/OneDrive-Personal/DevGem/projects/forma-3d-connect/sec/managed-postgresql/db-info.txt`) |
| `SHOPIFY_SHOP_DOMAIN` | No | Your Shopify store domain |
| `SHOPIFY_API_KEY` | Yes | Shopify API key |
| `SHOPIFY_API_SECRET` | Yes | Shopify API secret |
| `SHOPIFY_ACCESS_TOKEN` | Yes | Shopify access token |
| `SHOPIFY_WEBHOOK_SECRET` | Yes | Shopify webhook secret |
| `SHOPIFY_API_VERSION` | No | `2024-01` |
| `SENTRY_DSN` | Yes | Sentry DSN |
### Step 4: Link Variable Group to Pipeline
In `azure-pipelines.yml`, ensure the variable group is linked:
```yaml
variables:
- group: forma3d-staging
```
Step 5: Verify Droplet Configuration¶
Ensure the staging droplet has:
- Docker installed:
docker --version - Docker Compose installed:
docker compose version - Deployment directory:
/opt/forma3d/ - Docker Compose file:
/opt/forma3d/docker-compose.yml - Traefik config:
/opt/forma3d/traefik.yml - Certificates directory:
/opt/forma3d/certs/
Staging URLs¶
After successful deployment:
- API: https://staging-connect-api.forma3d.be
- Web: https://staging-connect.forma3d.be
- Health Check: https://staging-connect-api.forma3d.be/health
Troubleshooting Deployment¶
Pipeline fails at "Deploy to Staging Server":
- Verify SSH key is uploaded to Secure Files
- Check droplet IP is correct
- Ensure droplet allows SSH from Azure DevOps agents
Containers not starting:
- SSH to droplet:
ssh root@167.172.45.47 - Check logs:
docker compose logs - Verify environment variables in
.env
TLS certificates not issued:
- Check Traefik logs:
docker logs forma3d-traefik - Verify DNS points to droplet IP
- Ensure ports 80/443 are open
### Update Existing Sections
Also update the existing sections in README.md:
1. **Current Features section** — Add deployment features
2. **Available Scripts section** — Add deployment-related scripts if any
3. **Project Structure section** — Add `deployment/` folder
---
**END OF PROMPT**
---
_This prompt builds on the Phase 1b foundation. The AI should implement all Phase 1c deployment features while maintaining the established code style, architectural patterns, and testing standards._