Skip to content

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-devops and /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:

  1. Migrations run BEFORE container starts — Executed in the deployment pipeline
  2. Command: npx prisma migrate deploy — Safe, idempotent, production-ready
  3. 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 deploy only 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 down during deployments (causes downtime)
  • Modify production deployment stage (keep as placeholder)

🎬 Execution Order

  1. Create API Dockerfile in apps/api/Dockerfile
  2. Create Web Dockerfile in apps/web/Dockerfile
  3. Create Nginx config in apps/web/nginx.conf
  4. Create deployment directory deployment/staging/
  5. Create Docker Compose for staging
  6. Create Traefik config
  7. Create deployment script
  8. Update Azure Pipeline with Package and Deploy stages
  9. Upload secrets to Azure DevOps
  10. Configure droplet with Docker and Docker Compose
  11. Run first deployment
  12. Verify staging URLs
  13. 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

  1. Push to develop branch
  2. Pipeline should run: Validate → Test → Build → Package → Deploy Staging
  3. Staging URLs should be accessible
  4. Images tagged with YYYYMMDDhhmmss format

🔗 Phase 1c Exit Criteria

  • Docker images build and push successfully
  • Pipeline instance naming follows YYYYMMDDhhmmss format
  • 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:

  1. Docker installed: docker --version
  2. Docker Compose installed: docker compose version
  3. Deployment directory: /opt/forma3d/
  4. Docker Compose file: /opt/forma3d/docker-compose.yml
  5. Traefik config: /opt/forma3d/traefik.yml
  6. 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._