feat: production infra — nginx configs, deploy script, security hardening
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 58s
Deploy / Build Web Image (push) Failing after 14s
Deploy / Rollback Production (push) Has been skipped
CI / E2E Tests (push) Has been skipped
Deploy / Build API Image (push) Failing after 3m8s
Deploy / Build AI Services Image (push) Failing after 10s
E2E Tests / Playwright E2E (push) Failing after 1m21s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped

- Add Nginx reverse-proxy configs for api.goodgo.vn and platform.goodgo.vn
  with SSL, gzip, rate limiting, security headers, and WebSocket support
- Add Cloudflare DNS setup script for A/AAAA/CNAME records
- Add server-setup.sh for Ubuntu provisioning (Docker, fail2ban, UFW,
  swap, unattended-upgrades)
- Add deploy-production.sh for manual production deployments
- Add env.production.example with all required environment variables
- Bind container ports to 127.0.0.1 in docker-compose.prod.yml
  (security: prevent direct access bypassing Nginx)
- Fix deploy workflow: add -T flag to exec, sync Nginx configs,
  copy pgbouncer and backup configs to server

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-13 14:11:25 +07:00
parent b93c28fa01
commit e5f7acf7da
9 changed files with 946 additions and 6 deletions

View File

@@ -197,9 +197,11 @@ jobs:
DEPLOY_KEY: ${{ secrets.STAGING_SSH_KEY }}
IMAGE_TAG: ${{ github.sha }}
run: |
# Copy production compose and deploy
# Copy production compose, monitoring, and infra configs
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/"
scp -i ~/.ssh/deploy_key -r infra/pgbouncer/ "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/infra/pgbouncer/"
scp -i ~/.ssh/deploy_key -r scripts/backup/ "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/scripts/backup/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << DEPLOY_SCRIPT
cd ~/goodgo
@@ -218,12 +220,29 @@ jobs:
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
docker compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
# Cleanup old images
docker image prune -f
DEPLOY_SCRIPT
- name: Sync Nginx configs
env:
DEPLOY_HOST: ${{ secrets.STAGING_HOST }}
DEPLOY_USER: ${{ secrets.STAGING_USER }}
DEPLOY_KEY: ${{ secrets.STAGING_SSH_KEY }}
run: |
scp -i ~/.ssh/deploy_key infra/nginx/*.conf \
"$DEPLOY_USER@$DEPLOY_HOST:/tmp/goodgo-nginx/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'NGINX_SCRIPT'
sudo mkdir -p /tmp/goodgo-nginx
sudo cp /tmp/goodgo-nginx/*.conf /etc/nginx/sites-available/ 2>/dev/null || true
for conf in /etc/nginx/sites-available/*goodgo*; do
[ -f "$conf" ] && sudo ln -sf "$conf" /etc/nginx/sites-enabled/
done
sudo nginx -t && sudo systemctl reload nginx
NGINX_SCRIPT
- name: Verify staging deployment
env:
STAGING_URL: ${{ secrets.STAGING_URL }}
@@ -372,8 +391,11 @@ jobs:
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DEPLOY_HOST" >> ~/.ssh/known_hosts 2>/dev/null
# Copy configs
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/"
scp -i ~/.ssh/deploy_key -r infra/pgbouncer/ "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/infra/pgbouncer/"
scp -i ~/.ssh/deploy_key -r scripts/backup/ "$DEPLOY_USER@$DEPLOY_HOST:~/goodgo/scripts/backup/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << DEPLOY_SCRIPT
cd ~/goodgo
@@ -389,11 +411,27 @@ jobs:
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 compose -f docker-compose.prod.yml exec -T api npx prisma migrate deploy
docker image prune -f
DEPLOY_SCRIPT
- name: Sync Nginx configs (production)
env:
DEPLOY_HOST: ${{ secrets.PRODUCTION_HOST }}
DEPLOY_USER: ${{ secrets.PRODUCTION_USER }}
DEPLOY_KEY: ${{ secrets.PRODUCTION_SSH_KEY }}
run: |
scp -i ~/.ssh/deploy_key infra/nginx/*.conf \
"$DEPLOY_USER@$DEPLOY_HOST:/tmp/goodgo-nginx/"
ssh -i ~/.ssh/deploy_key "$DEPLOY_USER@$DEPLOY_HOST" << 'NGINX_SCRIPT'
sudo cp /tmp/goodgo-nginx/*.conf /etc/nginx/sites-available/ 2>/dev/null || true
for conf in /etc/nginx/sites-available/*goodgo*; do
[ -f "$conf" ] && sudo ln -sf "$conf" /etc/nginx/sites-enabled/
done
sudo nginx -t && sudo systemctl reload nginx
NGINX_SCRIPT
- name: Verify production deployment
env:
PRODUCTION_URL: ${{ secrets.PRODUCTION_URL }}

View File

@@ -9,7 +9,7 @@ services:
container_name: goodgo-api
restart: unless-stopped
ports:
- '${API_PORT:-3001}:3001'
- '127.0.0.1:${API_PORT:-3001}:3001'
environment:
NODE_ENV: production
DATABASE_URL: postgresql://${DB_USER}:${DB_PASSWORD}@pgbouncer:6432/${DB_NAME}
@@ -66,7 +66,7 @@ services:
container_name: goodgo-web
restart: unless-stopped
ports:
- '${WEB_PORT:-3000}:3000'
- '127.0.0.1:${WEB_PORT:-3000}:3000'
environment:
NODE_ENV: production
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://api:3001}
@@ -479,7 +479,7 @@ services:
container_name: goodgo-grafana
restart: unless-stopped
ports:
- '${GRAFANA_PORT:-3002}:3000'
- '127.0.0.1:${GRAFANA_PORT:-3002}:3000'
environment:
GF_SECURITY_ADMIN_USER__FILE: /run/secrets/grafana_admin_user
GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin_password

192
infra/cloudflare-dns.sh Executable file
View File

@@ -0,0 +1,192 @@
#!/usr/bin/env bash
# ==============================================================================
# GoodGo Platform — Cloudflare DNS Setup
# Adds DNS records for platform.goodgo.vn, api.goodgo.vn, grafana.goodgo.vn
#
# Prerequisites:
# export CF_API_TOKEN="your-cloudflare-api-token"
# export CF_ZONE_ID="your-goodgo-vn-zone-id"
#
# Usage:
# ./infra/cloudflare-dns.sh
# ./infra/cloudflare-dns.sh --dry-run # Preview without creating
# ./infra/cloudflare-dns.sh --delete # Remove records
# ==============================================================================
set -euo pipefail
# ── Configuration ─────────────────────────────────────────────────────────────
CF_API_TOKEN="${CF_API_TOKEN:?Error: Set CF_API_TOKEN environment variable}"
CF_ZONE_ID="${CF_ZONE_ID:?Error: Set CF_ZONE_ID environment variable}"
TARGET_IP="${TARGET_IP:-185.225.232.65}"
CF_API="https://api.cloudflare.com/client/v4"
DRY_RUN=false
DELETE=false
# Parse flags
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
--delete) DELETE=true ;;
esac
done
# DNS records to manage
declare -a SUBDOMAINS=("platform" "api" "grafana")
# ── Colors ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
log() { echo -e "${GREEN}[DNS]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
info() { echo -e "${CYAN}[INFO]${NC} $*"; }
# ── Helpers ───────────────────────────────────────────────────────────────────
cf_api() {
local method="$1"
local endpoint="$2"
shift 2
curl -s -X "$method" \
"${CF_API}${endpoint}" \
-H "Authorization: Bearer ${CF_API_TOKEN}" \
-H "Content-Type: application/json" \
"$@"
}
get_record_id() {
local name="$1"
cf_api GET "/zones/${CF_ZONE_ID}/dns_records?type=A&name=${name}.goodgo.vn" \
| jq -r '.result[0].id // empty'
}
# ── Verify API Token ─────────────────────────────────────────────────────────
log "Verifying Cloudflare API token..."
VERIFY=$(cf_api GET "/user/tokens/verify")
if [ "$(echo "$VERIFY" | jq -r '.success')" != "true" ]; then
err "Invalid Cloudflare API token!"
echo "$VERIFY" | jq .
exit 1
fi
log "API token verified."
# ── Verify Zone ──────────────────────────────────────────────────────────────
ZONE_NAME=$(cf_api GET "/zones/${CF_ZONE_ID}" | jq -r '.result.name')
if [ "$ZONE_NAME" != "goodgo.vn" ]; then
err "Zone ID does not match goodgo.vn! Got: ${ZONE_NAME}"
exit 1
fi
log "Zone verified: ${ZONE_NAME}"
# ── Process DNS Records ─────────────────────────────────────────────────────
echo ""
log "=========================================="
if $DELETE; then
log " Deleting DNS records"
elif $DRY_RUN; then
log " DRY RUN — no changes will be made"
else
log " Creating/Updating DNS records"
fi
log " Target IP: ${TARGET_IP}"
log "=========================================="
echo ""
for sub in "${SUBDOMAINS[@]}"; do
FQDN="${sub}.goodgo.vn"
EXISTING_ID=$(get_record_id "$sub")
if $DELETE; then
if [ -n "$EXISTING_ID" ]; then
if $DRY_RUN; then
info "[DRY RUN] Would delete: ${FQDN} (ID: ${EXISTING_ID})"
else
RESULT=$(cf_api DELETE "/zones/${CF_ZONE_ID}/dns_records/${EXISTING_ID}")
if [ "$(echo "$RESULT" | jq -r '.success')" = "true" ]; then
log "Deleted: ${FQDN}"
else
err "Failed to delete ${FQDN}: $(echo "$RESULT" | jq -r '.errors[0].message')"
fi
fi
else
warn "Record not found: ${FQDN} — skipping delete"
fi
continue
fi
RECORD_DATA=$(cat <<EOF
{
"type": "A",
"name": "${sub}",
"content": "${TARGET_IP}",
"ttl": 1,
"proxied": true,
"comment": "GoodGo Platform — managed by infra/cloudflare-dns.sh"
}
EOF
)
if [ -n "$EXISTING_ID" ]; then
# Update existing record
if $DRY_RUN; then
info "[DRY RUN] Would update: ${FQDN}${TARGET_IP} (Proxied, ID: ${EXISTING_ID})"
else
RESULT=$(cf_api PUT "/zones/${CF_ZONE_ID}/dns_records/${EXISTING_ID}" -d "$RECORD_DATA")
if [ "$(echo "$RESULT" | jq -r '.success')" = "true" ]; then
log "Updated: ${FQDN}${TARGET_IP} (Proxied)"
else
err "Failed to update ${FQDN}: $(echo "$RESULT" | jq -r '.errors[0].message')"
fi
fi
else
# Create new record
if $DRY_RUN; then
info "[DRY RUN] Would create: ${FQDN}${TARGET_IP} (Proxied)"
else
RESULT=$(cf_api POST "/zones/${CF_ZONE_ID}/dns_records" -d "$RECORD_DATA")
if [ "$(echo "$RESULT" | jq -r '.success')" = "true" ]; then
NEW_ID=$(echo "$RESULT" | jq -r '.result.id')
log "Created: ${FQDN}${TARGET_IP} (Proxied) [ID: ${NEW_ID}]"
else
err "Failed to create ${FQDN}: $(echo "$RESULT" | jq -r '.errors[0].message')"
fi
fi
fi
done
# ── Verify DNS ───────────────────────────────────────────────────────────────
if ! $DRY_RUN && ! $DELETE; then
echo ""
log "Verifying DNS records..."
echo ""
for sub in "${SUBDOMAINS[@]}"; do
FQDN="${sub}.goodgo.vn"
RECORD=$(cf_api GET "/zones/${CF_ZONE_ID}/dns_records?type=A&name=${FQDN}")
IP=$(echo "$RECORD" | jq -r '.result[0].content // "NOT FOUND"')
PROXIED=$(echo "$RECORD" | jq -r '.result[0].proxied // "N/A"')
info "${FQDN}${IP} (proxied: ${PROXIED})"
done
fi
echo ""
log "=========================================="
log " DNS setup complete!"
if ! $DELETE; then
log ""
log " Cloudflare SSL/TLS settings (manual):"
log " - SSL mode: Full (Strict)"
log " - Always Use HTTPS: ON"
log " - Minimum TLS: 1.2"
log " - HTTP/2: ON"
log " - Brotli: ON"
log ""
log " Generate Origin Certificate at:"
log " Cloudflare Dashboard → SSL/TLS → Origin Server"
log " Save as: /etc/ssl/goodgo/origin.pem"
log " /etc/ssl/goodgo/origin-key.pem"
fi
log "=========================================="

View File

@@ -0,0 +1,81 @@
# ==============================================================================
# GoodGo Platform — Production Environment Variables
# Copy to ~/goodgo/.env on the production server and fill in real values.
#
# WARNING: Never commit real secrets to version control!
# ==============================================================================
# ── Docker Registry ──────────────────────────────────────────────────────────
REGISTRY_URL=ghcr.io/velikho
IMAGE_TAG=latest
# ── PostgreSQL ───────────────────────────────────────────────────────────────
DB_NAME=goodgo_prod
DB_USER=goodgo
DB_PASSWORD=CHANGE_ME_strong_password_here
# ── PgBouncer ────────────────────────────────────────────────────────────────
PGBOUNCER_POOL_SIZE=20
PGBOUNCER_MAX_CLIENT_CONN=200
PGBOUNCER_ADMIN_PASSWORD=CHANGE_ME_pgbouncer_admin
PGBOUNCER_STATS_PASSWORD=CHANGE_ME_pgbouncer_stats
# ── Redis ────────────────────────────────────────────────────────────────────
REDIS_PASSWORD=CHANGE_ME_redis_password
# ── Authentication (JWT) ─────────────────────────────────────────────────────
# Generate with: openssl rand -base64 64
JWT_SECRET=CHANGE_ME_jwt_secret_64_chars
JWT_REFRESH_SECRET=CHANGE_ME_jwt_refresh_secret_64_chars
# ── Typesense (Full-text Search) ─────────────────────────────────────────────
# Generate with: openssl rand -hex 32
TYPESENSE_API_KEY=CHANGE_ME_typesense_api_key
# ── MinIO (Object Storage) ───────────────────────────────────────────────────
MINIO_ACCESS_KEY=CHANGE_ME_minio_access_key
MINIO_SECRET_KEY=CHANGE_ME_minio_secret_key_min_32_chars
MINIO_BUCKET=goodgo-uploads
# ── AI Services ──────────────────────────────────────────────────────────────
AI_API_KEY=CHANGE_ME_ai_api_key
AI_RATE_LIMIT=60/minute
# ── Application URLs ─────────────────────────────────────────────────────────
NEXT_PUBLIC_API_URL=https://api.goodgo.vn
API_PORT=3001
WEB_PORT=3000
# ── VNPay Payment Gateway ───────────────────────────────────────────────────
VNPAY_TMN_CODE=CHANGE_ME_vnpay_merchant_code
VNPAY_HASH_SECRET=CHANGE_ME_vnpay_hash_secret
VNPAY_URL=https://pay.vnpay.vn/vpcpay.html
VNPAY_RETURN_URL=https://platform.goodgo.vn/payment/return
# ── MoMo Payment Gateway (optional) ─────────────────────────────────────────
# MOMO_PARTNER_CODE=
# MOMO_ACCESS_KEY=
# MOMO_SECRET_KEY=
# ── ZaloPay Payment Gateway (optional) ──────────────────────────────────────
# ZALOPAY_APP_ID=
# ZALOPAY_KEY1=
# ZALOPAY_KEY2=
# ── Mapbox (Frontend Maps) ──────────────────────────────────────────────────
# MAPBOX_TOKEN=
# ── Monitoring ───────────────────────────────────────────────────────────────
GRAFANA_PORT=3002
GRAFANA_ROOT_URL=https://grafana.goodgo.vn
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=CHANGE_ME_grafana_admin_password
# ── Notifications ────────────────────────────────────────────────────────────
SLACK_WEBHOOK_URL=
# ── Database Backups ─────────────────────────────────────────────────────────
BACKUP_RETENTION_DAYS=7
# ── Prisma (auto-migrate on deploy) ─────────────────────────────────────────
RUN_MIGRATIONS=false

View File

@@ -0,0 +1,100 @@
# ==============================================================================
# api.goodgo.vn — NestJS API Backend
# Proxied by Cloudflare (Full Strict SSL) → Nginx → Docker (127.0.0.1:3001)
# ==============================================================================
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name api.goodgo.vn;
# Cloudflare Origin Certificate
ssl_certificate /etc/ssl/goodgo/origin.pem;
ssl_certificate_key /etc/ssl/goodgo/origin-key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Security headers
add_header X-Frame-Options DENY 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;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Request size limit (file uploads)
client_max_body_size 50m;
# API endpoints
location / {
# Rate limiting (defined in /etc/nginx/conf.d/performance.conf)
limit_req zone=api_limit burst=50 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
# Standard proxy headers
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_set_header X-Forwarded-Host $host;
# Disable buffering for streaming responses
proxy_buffering off;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 120s;
proxy_read_timeout 120s;
}
# WebSocket endpoint for notifications/realtime
location /ws {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Longer timeout for persistent connections
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Health check (skip rate limiting + logging)
location /health {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
access_log off;
}
location /ready {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
access_log off;
}
# Metrics endpoint (restrict to Cloudflare IPs or internal)
location /metrics {
# Allow only from localhost (Prometheus scrapes from the same host)
allow 127.0.0.1;
allow ::1;
deny all;
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
access_log off;
}
# Logging
access_log /var/log/nginx/api.goodgo.vn.access.log;
error_log /var/log/nginx/api.goodgo.vn.error.log;
}

View File

@@ -0,0 +1,85 @@
# ==============================================================================
# platform.goodgo.vn — Next.js Web Frontend
# Proxied by Cloudflare (Full Strict SSL) → Nginx → Docker (127.0.0.1:3000)
# ==============================================================================
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name platform.goodgo.vn;
# Cloudflare Origin Certificate
ssl_certificate /etc/ssl/goodgo/origin.pem;
ssl_certificate_key /etc/ssl/goodgo/origin-key.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
# Security headers (supplement Cloudflare's)
add_header X-Frame-Options DENY 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;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Trust Cloudflare's Authenticated Origin Pull
# ssl_client_certificate /etc/ssl/cloudflare/authenticated_origin_pull_ca.pem;
# ssl_verify_client on;
# Next.js application
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
# Standard proxy headers
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_set_header X-Forwarded-Host $host;
# WebSocket support (Next.js HMR in dev, real-time features)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Next.js specific: disable buffering for streaming/SSR
proxy_buffering off;
proxy_cache off;
# Timeouts
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
}
# Next.js static assets — long cache
location /_next/static/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
# Aggressive caching for immutable hashed assets
proxy_cache_valid 200 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
}
# Favicon and static public files
location /favicon.ico {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
access_log off;
log_not_found off;
}
# Health check (for Cloudflare/monitoring)
location /api/health {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
access_log off;
}
# Logging
access_log /var/log/nginx/platform.goodgo.vn.access.log;
error_log /var/log/nginx/platform.goodgo.vn.error.log;
}

28
infra/nginx/redirect.conf Normal file
View File

@@ -0,0 +1,28 @@
# ==============================================================================
# HTTP → HTTPS redirect for all GoodGo domains
# Cloudflare also enforces this, but this catches direct-IP access.
# ==============================================================================
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name platform.goodgo.vn api.goodgo.vn grafana.goodgo.vn;
# Redirect all HTTP to HTTPS
return 301 https://$host$request_uri;
}
# Catch-all for direct IP access — return 444 (drop connection)
server {
listen 80;
listen [::]:80;
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name _;
ssl_certificate /etc/ssl/goodgo/origin.pem;
ssl_certificate_key /etc/ssl/goodgo/origin-key.pem;
# Drop connections that don't match any server_name
return 444;
}

264
infra/server-setup.sh Executable file
View File

@@ -0,0 +1,264 @@
#!/usr/bin/env bash
# ==============================================================================
# GoodGo Platform — VPS Server Setup Script
# Target: VelikSV01 (185.225.232.65) — Ubuntu 22.04+
#
# Usage:
# scp infra/server-setup.sh ubuntu@185.225.232.65:~/
# ssh ubuntu@185.225.232.65 'chmod +x ~/server-setup.sh && sudo ~/server-setup.sh'
# ==============================================================================
set -euo pipefail
# ── Colors ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[SETUP]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
# ── Pre-flight checks ────────────────────────────────────────────────────────
if [ "$(id -u)" -ne 0 ]; then
err "This script must be run as root (sudo)."
exit 1
fi
DEPLOY_USER="${DEPLOY_USER:-ubuntu}"
DEPLOY_DIR="/home/${DEPLOY_USER}/goodgo"
log "Starting GoodGo Platform server setup..."
log "Deploy user: ${DEPLOY_USER}"
log "Deploy dir: ${DEPLOY_DIR}"
# ── 1. System Updates ─────────────────────────────────────────────────────────
log "Updating system packages..."
apt-get update -qq
apt-get upgrade -y -qq
# ── 2. Install Essential Packages ─────────────────────────────────────────────
log "Installing essential packages..."
apt-get install -y -qq \
apt-transport-https \
ca-certificates \
curl \
gnupg \
lsb-release \
software-properties-common \
git \
jq \
htop \
unzip \
fail2ban \
ufw \
logrotate
# ── 3. Install Docker Engine ─────────────────────────────────────────────────
if command -v docker &>/dev/null; then
log "Docker already installed: $(docker --version)"
else
log "Installing Docker Engine..."
curl -fsSL https://get.docker.com | sh
# Add deploy user to docker group
usermod -aG docker "${DEPLOY_USER}"
log "Docker installed: $(docker --version)"
fi
# Ensure Docker starts on boot
systemctl enable docker
systemctl start docker
# ── 4. Install Docker Compose v2 (plugin) ────────────────────────────────────
if docker compose version &>/dev/null; then
log "Docker Compose already installed: $(docker compose version --short)"
else
log "Installing Docker Compose v2 plugin..."
DOCKER_COMPOSE_VERSION=$(curl -s https://api.github.com/repos/docker/compose/releases/latest | jq -r .tag_name)
mkdir -p /usr/local/lib/docker/cli-plugins
curl -fsSL "https://github.com/docker/compose/releases/download/${DOCKER_COMPOSE_VERSION}/docker-compose-linux-$(uname -m)" \
-o /usr/local/lib/docker/cli-plugins/docker-compose
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose
log "Docker Compose installed: $(docker compose version --short)"
fi
# ── 5. Install Nginx ──────────────────────────────────────────────────────────
if command -v nginx &>/dev/null; then
log "Nginx already installed: $(nginx -v 2>&1)"
else
log "Installing Nginx..."
apt-get install -y -qq nginx
log "Nginx installed: $(nginx -v 2>&1)"
fi
systemctl enable nginx
systemctl start nginx
# ── 6. Configure Firewall (ufw) ──────────────────────────────────────────────
log "Configuring firewall (ufw)..."
ufw --force reset
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp comment 'SSH'
ufw allow 80/tcp comment 'HTTP — redirect to HTTPS'
ufw allow 443/tcp comment 'HTTPS'
ufw --force enable
log "Firewall configured. Active rules:"
ufw status verbose
# ── 7. Configure fail2ban ─────────────────────────────────────────────────────
log "Configuring fail2ban..."
cat > /etc/fail2ban/jail.local << 'F2B_CONF'
[DEFAULT]
bantime = 3600
findtime = 600
maxretry = 5
[sshd]
enabled = true
port = ssh
logpath = %(sshd_log)s
maxretry = 3
[nginx-http-auth]
enabled = true
[nginx-botsearch]
enabled = true
F2B_CONF
systemctl enable fail2ban
systemctl restart fail2ban
log "fail2ban configured and running."
# ── 8. Create Directory Structure ────────────────────────────────────────────
log "Creating deployment directory structure..."
mkdir -p "${DEPLOY_DIR}"/{monitoring/{prometheus,grafana/{provisioning,dashboards},loki,promtail,alertmanager},scripts/backup,infra/pgbouncer}
chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${DEPLOY_DIR}"
log "Directory structure created at ${DEPLOY_DIR}"
# ── 9. Create SSL Directory ──────────────────────────────────────────────────
log "Creating SSL certificate directory..."
mkdir -p /etc/ssl/goodgo
chmod 700 /etc/ssl/goodgo
log "SSL directory: /etc/ssl/goodgo (place origin.pem + origin-key.pem here)"
# ── 10. Nginx Base Configuration ─────────────────────────────────────────────
log "Creating Nginx base configuration..."
# Create sites-available/sites-enabled structure if not exists
mkdir -p /etc/nginx/sites-available /etc/nginx/sites-enabled
# Check if nginx.conf already includes sites-enabled
if ! grep -q 'sites-enabled' /etc/nginx/nginx.conf; then
warn "Adding 'include sites-enabled' to nginx.conf"
sed -i '/http {/a \ include /etc/nginx/sites-enabled/*;' /etc/nginx/nginx.conf
fi
# Remove default site
rm -f /etc/nginx/sites-enabled/default
# Nginx performance tuning
cat > /etc/nginx/conf.d/performance.conf << 'NGINX_PERF'
# GoodGo Platform — Nginx Performance Tuning
# Rate limiting zone (used by API vhost)
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=30r/s;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
# Proxy settings
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffers 32 8k;
# Security
server_tokens off;
NGINX_PERF
nginx -t && systemctl reload nginx
log "Nginx base configuration ready."
# ── 11. Docker Log Rotation ──────────────────────────────────────────────────
log "Configuring Docker log rotation..."
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DOCKER_LOG'
{
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "5"
},
"storage-driver": "overlay2"
}
DOCKER_LOG
systemctl restart docker
log "Docker log rotation configured."
# ── 12. Swap (if <4GB RAM) ───────────────────────────────────────────────────
TOTAL_MEM=$(free -m | awk '/^Mem:/{print $2}')
if [ "$TOTAL_MEM" -lt 4096 ]; then
if [ ! -f /swapfile ]; then
log "Low RAM (${TOTAL_MEM}MB). Creating 4GB swap..."
fallocate -l 4G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
log "Swap created and enabled."
else
log "Swap already exists."
fi
else
log "RAM: ${TOTAL_MEM}MB — swap not needed."
fi
# ── 13. Kernel Tuning (for Docker/PostgreSQL) ────────────────────────────────
log "Applying kernel tuning..."
cat >> /etc/sysctl.conf << 'SYSCTL'
# GoodGo — Docker + PostgreSQL tuning
vm.overcommit_memory = 1
vm.swappiness = 10
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.ipv4.ip_local_port_range = 1024 65535
fs.file-max = 2097152
SYSCTL
sysctl -p >/dev/null 2>&1 || true
log "Kernel parameters tuned."
# ── Summary ──────────────────────────────────────────────────────────────────
echo ""
log "=========================================="
log " Server setup complete!"
log "=========================================="
log ""
log " Next steps:"
log " 1. Place Cloudflare Origin Certificate:"
log " /etc/ssl/goodgo/origin.pem"
log " /etc/ssl/goodgo/origin-key.pem"
log ""
log " 2. Copy Nginx vhost configs to:"
log " /etc/nginx/sites-available/"
log " Then symlink to /etc/nginx/sites-enabled/"
log ""
log " 3. Create .env file at:"
log " ${DEPLOY_DIR}/.env"
log ""
log " 4. Login to GHCR:"
log " docker login ghcr.io"
log ""
log " 5. Deploy:"
log " cd ${DEPLOY_DIR}"
log " docker compose -f docker-compose.prod.yml up -d"
log "=========================================="

152
scripts/deploy-production.sh Executable file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# ==============================================================================
# GoodGo Platform — Manual Production Deploy Script
# Backup for CI/CD pipeline. Use when GitHub Actions is unavailable.
#
# Usage (from the server):
# cd ~/goodgo
# ./deploy-production.sh [image-tag]
#
# Usage (from local machine):
# ssh ubuntu@185.225.232.65 'cd ~/goodgo && ./deploy-production.sh abc1234'
# ==============================================================================
set -euo pipefail
# ── Configuration ─────────────────────────────────────────────────────────────
COMPOSE_FILE="docker-compose.prod.yml"
IMAGE_TAG="${1:-latest}"
HEALTH_URL="http://127.0.0.1:3001/health"
HEALTH_RETRIES=15
HEALTH_INTERVAL=5
ROLLBACK_ON_FAIL=true
# ── Colors ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'
log() { echo -e "${GREEN}[DEPLOY]${NC} $(date +%H:%M:%S) $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $(date +%H:%M:%S) $*"; }
err() { echo -e "${RED}[ERROR]${NC} $(date +%H:%M:%S) $*" >&2; }
info() { echo -e "${CYAN}[INFO]${NC} $(date +%H:%M:%S) $*"; }
# ── Pre-flight Checks ────────────────────────────────────────────────────────
if [ ! -f "$COMPOSE_FILE" ]; then
err "Compose file not found: $COMPOSE_FILE"
err "Are you in the ~/goodgo directory?"
exit 1
fi
if [ ! -f ".env" ]; then
err ".env file not found. Copy from infra/env.production.example"
exit 1
fi
log "=========================================="
log " GoodGo Platform — Production Deploy"
log " Image tag: ${IMAGE_TAG}"
log " Compose: ${COMPOSE_FILE}"
log "=========================================="
echo ""
# ── Step 1: Record Current State (for rollback) ──────────────────────────────
log "Step 1/6: Recording current state for rollback..."
PREV_API=$(docker inspect --format='{{.Config.Image}}' goodgo-api 2>/dev/null || echo "none")
PREV_WEB=$(docker inspect --format='{{.Config.Image}}' goodgo-web 2>/dev/null || echo "none")
PREV_AI=$(docker inspect --format='{{.Config.Image}}' goodgo-ai-services 2>/dev/null || echo "none")
info "Previous API: ${PREV_API}"
info "Previous Web: ${PREV_WEB}"
info "Previous AI: ${PREV_AI}"
# ── Step 2: Pull New Images ──────────────────────────────────────────────────
log "Step 2/6: Pulling new images (tag: ${IMAGE_TAG})..."
export IMAGE_TAG
docker compose -f "$COMPOSE_FILE" pull api web ai-services
log "Images pulled successfully."
# ── Step 3: Rolling Update ───────────────────────────────────────────────────
log "Step 3/6: Rolling update (zero-downtime)..."
info "Updating API..."
docker compose -f "$COMPOSE_FILE" up -d --no-deps --wait api
info "API updated and healthy."
info "Updating Web..."
docker compose -f "$COMPOSE_FILE" up -d --no-deps --wait web
info "Web updated and healthy."
info "Updating AI Services..."
docker compose -f "$COMPOSE_FILE" up -d --no-deps --wait ai-services
info "AI Services updated and healthy."
log "Rolling update complete."
# ── Step 4: Database Migrations ──────────────────────────────────────────────
log "Step 4/6: Running database migrations..."
docker compose -f "$COMPOSE_FILE" exec -T api npx prisma migrate deploy
log "Migrations complete."
# ── Step 5: Health Check Verification ────────────────────────────────────────
log "Step 5/6: Verifying deployment health..."
HEALTHY=false
for i in $(seq 1 "$HEALTH_RETRIES"); do
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
HEALTHY=true
break
fi
info "Waiting for health check... (${i}/${HEALTH_RETRIES})"
sleep "$HEALTH_INTERVAL"
done
if $HEALTHY; then
log "Health check passed!"
else
err "Health check failed after ${HEALTH_RETRIES} attempts!"
if $ROLLBACK_ON_FAIL; then
warn "Initiating rollback..."
# Rollback: stop current, docker compose will use previously cached images
docker compose -f "$COMPOSE_FILE" stop api web ai-services
docker compose -f "$COMPOSE_FILE" up -d --wait api web ai-services
warn "Rollback complete. Verifying..."
sleep 5
if curl -sf "$HEALTH_URL" > /dev/null 2>&1; then
warn "Services recovered after rollback."
else
err "CRITICAL: Services still unhealthy after rollback!"
err "Manual intervention required."
fi
fi
exit 1
fi
# ── Step 6: Cleanup ──────────────────────────────────────────────────────────
log "Step 6/6: Cleaning up old images..."
docker image prune -f
log "Cleanup complete."
# ── Summary ──────────────────────────────────────────────────────────────────
echo ""
log "=========================================="
log " Deployment successful!"
log "=========================================="
log ""
log " Services:"
info " API: $(docker inspect --format='{{.Config.Image}}' goodgo-api)"
info " Web: $(docker inspect --format='{{.Config.Image}}' goodgo-web)"
info " AI: $(docker inspect --format='{{.Config.Image}}' goodgo-ai-services)"
log ""
log " Endpoints:"
info " Web: https://platform.goodgo.vn"
info " API: https://api.goodgo.vn"
info " Grafana: https://grafana.goodgo.vn"
log ""
log " Run smoke tests:"
info " ./scripts/smoke-test.sh https://api.goodgo.vn"
log "=========================================="