feat(deploy): add production Dockerfiles and CI/CD pipeline

- Multi-stage Dockerfile for apps/api (NestJS) and apps/web (Next.js standalone)
- Production docker-compose.prod.yml with all services, health checks, and security
- Real deploy.yml pipeline: build → push to GHCR → deploy staging/production
- .dockerignore for optimized build context
- Enable Next.js standalone output mode

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 04:03:27 +07:00
parent a53c1f016f
commit 7c9f682046
6 changed files with 560 additions and 35 deletions

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
node_modules
.next
dist
*.tsbuildinfo
.git
.github
.husky
docs
e2e
playwright-report
monitoring
*.md
!README.md
.env*
.eslintcache
coverage
.turbo

View File

@@ -1,6 +1,8 @@
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
inputs:
environment:
@@ -13,13 +15,20 @@ on:
- production
concurrency:
group: deploy-${{ github.event.inputs.environment }}
group: deploy-${{ inputs.environment || 'staging' }}
cancel-in-progress: false
env:
REGISTRY: ghcr.io
REGISTRY_URL: ghcr.io/${{ github.repository_owner }}
jobs:
build:
name: Build Docker Images
build-api:
name: Build API Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
@@ -28,43 +37,239 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# TODO: Configure container registry login
# - name: Login to Container Registry
# uses: docker/login-action@v3
# with:
# registry: ${{ secrets.REGISTRY_URL }}
# username: ${{ secrets.REGISTRY_USERNAME }}
# password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# TODO: Build and push Docker images
# - name: Build API image
# uses: docker/build-push-action@v6
# with:
# context: .
# file: apps/api/Dockerfile
# push: true
# tags: ${{ secrets.REGISTRY_URL }}/goodgo-api:${{ github.sha }}
# cache-from: type=gha
# cache-to: type=gha,mode=max
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_URL }}/goodgo-api
tags: |
type=sha,prefix=
type=ref,event=branch
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Placeholder
run: echo "Docker build steps TBD — configure registry and Dockerfiles first"
- name: Build and push API image
uses: docker/build-push-action@v6
with:
context: .
file: apps/api/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=api
cache-to: type=gha,mode=max,scope=api
deploy:
name: Deploy to ${{ github.event.inputs.environment }}
needs: build
build-web:
name: Build Web Image
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.environment }}
permissions:
contents: read
packages: write
steps:
# TODO: Deploy to Kubernetes
# - name: Configure kubectl
# uses: azure/setup-kubectl@v4
- name: Checkout
uses: actions/checkout@v4
# - name: Deploy
# run: |
# kubectl set image deployment/goodgo-api \
# api=${{ secrets.REGISTRY_URL }}/goodgo-api:${{ github.sha }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Placeholder
run: echo "Deploy steps TBD — configure Kubernetes and environment secrets first"
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_URL }}/goodgo-web
tags: |
type=sha,prefix=
type=ref,event=branch
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Web image
uses: docker/build-push-action@v6
with:
context: .
file: apps/web/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=web
cache-to: type=gha,mode=max,scope=web
build-ai:
name: Build AI Services Image
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY_URL }}/goodgo-ai-services
tags: |
type=sha,prefix=
type=ref,event=branch
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push AI Services image
uses: docker/build-push-action@v6
with:
context: ./libs/ai-services
file: libs/ai-services/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=ai
cache-to: type=gha,mode=max,scope=ai
deploy-staging:
name: Deploy to Staging
needs: [build-api, build-web, build-ai]
if: github.event_name == 'push' || inputs.environment == 'staging'
runs-on: ubuntu-latest
environment: staging
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to staging
env:
DEPLOY_HOST: ${{ secrets.STAGING_HOST }}
DEPLOY_USER: ${{ secrets.STAGING_USER }}
DEPLOY_KEY: ${{ secrets.STAGING_SSH_KEY }}
IMAGE_TAG: ${{ github.sha }}
run: |
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
# Copy production compose and deploy
scp -i ~/.ssh/deploy_key docker-compose.prod.yml "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/"
scp -i ~/.ssh/deploy_key -r monitoring/ "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/monitoring/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'DEPLOY_SCRIPT'
cd ~/goodgo
export IMAGE_TAG="${IMAGE_TAG}"
export REGISTRY_URL="${REGISTRY_URL}"
# Login to GHCR
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
# Pull new images
docker compose -f docker-compose.prod.yml pull api web ai-services
# Rolling update — zero downtime
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
# Run database migrations
docker compose -f docker-compose.prod.yml exec api npx prisma migrate deploy
# Cleanup old images
docker image prune -f
DEPLOY_SCRIPT
- name: Verify staging deployment
env:
STAGING_URL: ${{ secrets.STAGING_URL }}
run: |
for i in $(seq 1 10); do
if curl -sf "$STAGING_URL/health" > /dev/null 2>&1; then
echo "Staging deployment verified successfully"
exit 0
fi
echo "Waiting for staging to be ready... ($i/10)"
sleep 10
done
echo "Staging health check failed"
exit 1
deploy-production:
name: Deploy to Production
needs: [build-api, build-web, build-ai]
if: inputs.environment == 'production'
runs-on: ubuntu-latest
environment: production
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to production
env:
DEPLOY_HOST: ${{ secrets.PRODUCTION_HOST }}
DEPLOY_USER: ${{ secrets.PRODUCTION_USER }}
DEPLOY_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
IMAGE_TAG: ${{ github.sha }}
run: |
mkdir -p ~/.ssh
echo "$DEPLOY_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
scp -i ~/.ssh/deploy_key docker-compose.prod.yml "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/"
scp -i ~/.ssh/deploy_key -r monitoring/ "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/monitoring/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'DEPLOY_SCRIPT'
cd ~/goodgo
export IMAGE_TAG="${IMAGE_TAG}"
export REGISTRY_URL="${REGISTRY_URL}"
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin
docker compose -f docker-compose.prod.yml pull api web ai-services
# Rolling update with health checks
docker compose -f docker-compose.prod.yml up -d --no-deps --wait api
docker compose -f docker-compose.prod.yml up -d --no-deps --wait web
docker compose -f docker-compose.prod.yml up -d --no-deps --wait ai-services
docker compose -f docker-compose.prod.yml exec api npx prisma migrate deploy
docker image prune -f
DEPLOY_SCRIPT
- name: Verify production deployment
env:
PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }}
run: |
for i in $(seq 1 10); do
if curl -sf "$PRODUCTION_URL/health" > /dev/null 2>&1; then
echo "Production deployment verified successfully"
exit 0
fi
echo "Waiting for production to be ready... ($i/10)"
sleep 10
done
echo "Production health check failed"
exit 1

48
apps/api/Dockerfile Normal file
View File

@@ -0,0 +1,48 @@
# ---- Base ----
FROM node:22-slim AS base
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
WORKDIR /app
# ---- Dependencies ----
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY apps/api/package.json apps/api/
COPY libs/mcp-servers/package.json libs/mcp-servers/
COPY prisma/ prisma/
RUN pnpm install --frozen-lockfile --filter @goodgo/api...
# ---- Build ----
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=deps /app/libs/mcp-servers/node_modules ./libs/mcp-servers/node_modules
COPY tsconfig.base.json ./
COPY prisma/ prisma/
COPY libs/mcp-servers/ libs/mcp-servers/
COPY apps/api/ apps/api/
RUN pnpm --filter @goodgo/mcp-servers build 2>/dev/null || true
RUN cd apps/api && npx nest build
RUN npx prisma generate
# ---- Production ----
FROM node:22-slim AS production
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init && rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/apps/api/dist ./dist
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=build /app/prisma ./prisma
COPY --from=build /app/apps/api/package.json ./package.json
EXPOSE 3001
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD node -e "fetch('http://localhost:3001/health').then(r => { if (!r.ok) throw 1 })"
USER node
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "dist/main"]

42
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,42 @@
# ---- Base ----
FROM node:22-slim AS base
RUN corepack enable && corepack prepare pnpm@10.27.0 --activate
WORKDIR /app
# ---- Dependencies ----
FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY apps/web/package.json apps/web/
RUN pnpm install --frozen-lockfile --filter @goodgo/web...
# ---- Build ----
FROM base AS build
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
COPY tsconfig.base.json ./
COPY apps/web/ apps/web/
RUN cd apps/web && npx next build
# ---- Production ----
FROM node:22-slim AS production
RUN apt-get update && apt-get install -y --no-install-recommends dumb-init && rm -rf /var/lib/apt/lists/*
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV HOSTNAME=0.0.0.0
ENV PORT=3000
COPY --from=build /app/apps/web/public ./public
COPY --from=build /app/apps/web/.next/standalone ./
COPY --from=build /app/apps/web/.next/static ./.next/static
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD node -e "fetch('http://localhost:3000/api/health').then(r => { if (!r.ok) throw 1 }).catch(() => { process.exit(1) })"
USER node
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

View File

@@ -1,6 +1,7 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
output: 'standalone',
};
module.exports = nextConfig;

212
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,212 @@
services:
api:
image: ${REGISTRY_URL:-ghcr.io/goodgo}/goodgo-api:${IMAGE_TAG:-latest}
container_name: goodgo-api
restart: unless-stopped
ports:
- '${API_PORT:-3001}:3001'
environment:
NODE_ENV: production
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@postgres:5432/${DB_NAME}
REDIS_URL: redis://redis:6379
TYPESENSE_HOST: typesense
TYPESENSE_PORT: 8108
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY}
JWT_SECRET: ${JWT_SECRET}
MINIO_ENDPOINT: minio
MINIO_PORT: 9000
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY}
AI_SERVICES_URL: http://ai-services:8000
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
typesense:
condition: service_healthy
healthcheck:
test: ['CMD', 'node', '-e', "fetch('http://localhost:3001/health').then(r => { if (!r.ok) throw 1 })"]
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
networks:
- goodgo-net
web:
image: ${REGISTRY_URL:-ghcr.io/goodgo}/goodgo-web:${IMAGE_TAG:-latest}
container_name: goodgo-web
restart: unless-stopped
ports:
- '${WEB_PORT:-3000}:3000'
environment:
NODE_ENV: production
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://api:3001}
depends_on:
api:
condition: service_healthy
healthcheck:
test: ['CMD', 'node', '-e', "fetch('http://localhost:3000').then(r => { if (!r.ok) throw 1 }).catch(() => process.exit(1))"]
interval: 30s
timeout: 5s
retries: 3
start_period: 15s
networks:
- goodgo-net
postgres:
image: postgis/postgis:16-3.4
container_name: goodgo-postgres
restart: unless-stopped
ports:
- '${DB_PORT:-5432}:5432'
environment:
POSTGRES_DB: ${DB_NAME}
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U ${DB_USER} -d ${DB_NAME}']
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- goodgo-net
redis:
image: redis:7-alpine
container_name: goodgo-redis
restart: unless-stopped
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} --maxmemory 512mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
healthcheck:
test: ['CMD', 'redis-cli', '-a', '${REDIS_PASSWORD}', 'ping']
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- goodgo-net
typesense:
image: typesense/typesense:27.1
container_name: goodgo-typesense
restart: unless-stopped
environment:
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY}
TYPESENSE_DATA_DIR: /data
volumes:
- typesense_data:/data
healthcheck:
test: ['CMD', 'curl', '-sf', 'http://localhost:8108/health']
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
networks:
- goodgo-net
minio:
image: minio/minio:latest
container_name: goodgo-minio
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY}
volumes:
- minio_data:/data
healthcheck:
test: ['CMD', 'mc', 'ready', 'local']
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
networks:
- goodgo-net
ai-services:
image: ${REGISTRY_URL:-ghcr.io/goodgo}/goodgo-ai-services:${IMAGE_TAG:-latest}
container_name: goodgo-ai-services
restart: unless-stopped
environment:
AI_DEBUG: 'false'
AI_LOG_LEVEL: info
healthcheck:
test: ['CMD', 'python', '-c', 'import httpx; httpx.get("http://localhost:8000/health").raise_for_status()']
interval: 30s
timeout: 5s
retries: 5
start_period: 30s
networks:
- goodgo-net
prometheus:
image: prom/prometheus:v2.51.0
container_name: goodgo-prometheus
restart: unless-stopped
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.retention.time=30d'
- '--web.enable-lifecycle'
volumes:
- ./monitoring/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
healthcheck:
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:9090/-/healthy']
interval: 15s
timeout: 5s
retries: 3
start_period: 10s
networks:
- goodgo-net
grafana:
image: grafana/grafana:10.4.1
container_name: goodgo-grafana
restart: unless-stopped
ports:
- '${GRAFANA_PORT:-3002}:3000'
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
GF_USERS_ALLOW_SIGN_UP: 'false'
GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-http://localhost:3002}
volumes:
- ./monitoring/grafana/provisioning:/etc/grafana/provisioning:ro
- ./monitoring/grafana/dashboards:/var/lib/grafana/dashboards:ro
- grafana_data:/var/lib/grafana
depends_on:
prometheus:
condition: service_healthy
healthcheck:
test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:3000/api/health']
interval: 15s
timeout: 5s
retries: 3
start_period: 15s
networks:
- goodgo-net
volumes:
pgdata:
driver: local
redis_data:
driver: local
typesense_data:
driver: local
minio_data:
driver: local
prometheus_data:
driver: local
grafana_data:
driver: local
networks:
goodgo-net:
driver: bridge
name: goodgo-net