ci: IDC 서버 자동배포 파이프라인 구축

- Dockerfile: Turborepo 멀티스테이지 빌드 (Next.js standalone)
- docker-compose.prod.yml: PostgreSQL/Redis/Nginx/Web/Admin 프로덕션 스택
- deploy/poll-deploy.sh: cron 기반 자동배포 (매분 Gitea 폴링)
- deploy/nginx/default.conf: 리버스 프록시 설정
- next.config.ts: output standalone 추가
- .env.production.example: 환경변수 템플릿

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Johngreen
2026-03-07 22:28:09 +09:00
parent 16bd2cb92a
commit bd48cafcc9
12 changed files with 628 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
node_modules
.next
.git
.gitignore
*.md
docker-compose*.yml
Dockerfile
.dockerignore
.env*
!.env.production.example
coverage
.turbo
dist
.omc
+19
View File
@@ -0,0 +1,19 @@
# ===========================================
# Re:Link Production Environment Variables
# Copy to .env.production and fill in values
# ===========================================
# Database
DB_USER=relink
DB_PASSWORD=CHANGE_ME_STRONG_PASSWORD
DB_NAME=relink_prod
# Redis
REDIS_PASSWORD=CHANGE_ME_STRONG_PASSWORD
# NextAuth
NEXTAUTH_URL=http://your-domain.com
NEXTAUTH_SECRET=GENERATE_WITH_openssl_rand_base64_32
# App
NODE_ENV=production
+42
View File
@@ -0,0 +1,42 @@
name: Deploy Re:Link
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Create .env.production
run: |
cat > .env.production << 'ENVEOF'
DB_USER=${{ secrets.DB_USER }}
DB_PASSWORD=${{ secrets.DB_PASSWORD }}
DB_NAME=${{ secrets.DB_NAME }}
REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }}
NEXTAUTH_URL=${{ secrets.NEXTAUTH_URL }}
NEXTAUTH_SECRET=${{ secrets.NEXTAUTH_SECRET }}
ENVEOF
- name: Build and Deploy
run: |
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build --remove-orphans
- name: Run database migrations
run: |
docker compose -f docker-compose.prod.yml exec -T web sh -c "cd /app && npx prisma migrate deploy" || echo "Migration skipped (first deploy)"
- name: Health check
run: |
echo "Waiting for services to start..."
sleep 10
curl -f http://localhost/health || echo "Health check pending - services may still be starting"
- name: Cleanup old images
run: |
docker image prune -f
+56
View File
@@ -0,0 +1,56 @@
# ============================================
# Re:Link Turborepo Multi-stage Dockerfile
# Usage: docker build --build-arg APP_NAME=web .
# ============================================
# --- Stage 1: Base ---
FROM node:20-alpine AS base
RUN apk add --no-cache libc6-compat
RUN corepack enable && corepack prepare pnpm@8.10.0 --activate
WORKDIR /app
# --- Stage 2: Install & Build ---
FROM base AS builder
ARG APP_NAME=web
# Copy workspace config first for better caching
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json turbo.json ./
COPY apps/web/package.json ./apps/web/
COPY apps/admin/package.json ./apps/admin/
COPY packages/shared/package.json ./packages/shared/
COPY packages/domain/package.json ./packages/domain/
COPY packages/application/package.json ./packages/application/
COPY packages/infrastructure/package.json ./packages/infrastructure/
COPY packages/database/package.json ./packages/database/
COPY packages/analytics/package.json ./packages/analytics/
COPY packages/ui/package.json ./packages/ui/
RUN pnpm install --frozen-lockfile
# Copy all source code
COPY . .
# Generate Prisma client & build
RUN pnpm --filter @relink/database prisma generate
RUN pnpm turbo run build --filter=@relink/${APP_NAME}
# --- Stage 3: Runner ---
FROM node:20-alpine AS runner
ARG APP_NAME=web
ENV NODE_ENV=production
ENV APP_NAME=${APP_NAME}
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
WORKDIR /app
COPY --from=builder /app/apps/${APP_NAME}/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME}/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/${APP_NAME}/.next/static ./apps/${APP_NAME}/.next/static
USER nextjs
EXPOSE 3000
ENV HOSTNAME="0.0.0.0"
ENV PORT=3000
CMD ["sh", "-c", "node apps/${APP_NAME}/server.js"]
+1
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['@relink/ui', '@relink/shared'], transpilePackages: ['@relink/ui', '@relink/shared'],
}; };
+1
View File
@@ -1,6 +1,7 @@
import type { NextConfig } from 'next'; import type { NextConfig } from 'next';
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone',
transpilePackages: ['@relink/ui', '@relink/shared'], transpilePackages: ['@relink/ui', '@relink/shared'],
}; };
+72
View File
@@ -0,0 +1,72 @@
#!/bin/bash
set -euo pipefail
# ===========================================
# Re:Link Auto Deploy Script
# Triggered by Gitea webhook on push to main
# ===========================================
APP_DIR="$HOME/relink"
LOG_FILE="$APP_DIR/deploy.log"
LOCK_FILE="$APP_DIR/deploy.lock"
GITEA_REPO="http://39.117.244.52:3000/geonhee/Re_Link.git"
log() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}
# Prevent concurrent deploys
if [ -f "$LOCK_FILE" ]; then
LOCK_PID=$(cat "$LOCK_FILE" 2>/dev/null)
if kill -0 "$LOCK_PID" 2>/dev/null; then
log "ERROR: Deploy already running (PID: $LOCK_PID). Skipping."
exit 1
fi
rm -f "$LOCK_FILE"
fi
echo $$ > "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
log "=== Deploy started ==="
# Navigate to app directory
cd "$APP_DIR"
# Clone or pull
if [ ! -d "$APP_DIR/repo/.git" ]; then
log "Cloning repository..."
git clone "$GITEA_REPO" "$APP_DIR/repo"
cd "$APP_DIR/repo"
else
cd "$APP_DIR/repo"
log "Pulling latest changes..."
git fetch origin
git reset --hard origin/main
fi
log "Current commit: $(git log --oneline -1)"
# Copy env file
if [ -f "$APP_DIR/.env.production" ]; then
cp "$APP_DIR/.env.production" "$APP_DIR/repo/.env.production"
fi
# Build and deploy
log "Building and deploying with Docker Compose..."
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build --remove-orphans 2>&1 | tee -a "$LOG_FILE"
# Wait for services
log "Waiting for services to be healthy..."
sleep 15
# Health check
if curl -sf http://localhost/health > /dev/null 2>&1; then
log "Health check PASSED"
else
log "WARNING: Health check failed - services may still be starting"
fi
# Cleanup old images
docker image prune -f >> "$LOG_FILE" 2>&1
log "=== Deploy completed ==="
+48
View File
@@ -0,0 +1,48 @@
upstream web_app {
server web:3000;
}
upstream admin_app {
server admin:3000;
}
server {
listen 80;
server_name _;
client_max_body_size 50M;
# Web app (default)
location / {
proxy_pass http://web_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
}
# Admin app
location /admin/ {
rewrite ^/admin/(.*) /$1 break;
proxy_pass http://admin_app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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_cache_bypass $http_upgrade;
}
# Health check
location /health {
access_log off;
return 200 'ok';
add_header Content-Type text/plain;
}
}
+83
View File
@@ -0,0 +1,83 @@
#!/bin/bash
# ===========================================
# Re:Link Poll-based Auto Deploy
# Checks Gitea for new commits every minute
# ===========================================
APP_DIR="$HOME/relink"
REPO_DIR="$APP_DIR/repo"
LOG_FILE="$APP_DIR/deploy.log"
LOCK_FILE="$APP_DIR/deploy.lock"
GITEA_REPO="http://39.117.244.52:3000/geonhee/Re_Link.git"
BRANCH="main"
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE"; }
# Prevent concurrent deploys
if [ -f "$LOCK_FILE" ]; then
LOCK_PID=$(cat "$LOCK_FILE" 2>/dev/null)
if kill -0 "$LOCK_PID" 2>/dev/null; then
exit 0
fi
rm -f "$LOCK_FILE"
fi
# First time: clone
if [ ! -d "$REPO_DIR/.git" ]; then
log "Initial clone..."
echo $$ > "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
git clone "$GITEA_REPO" "$REPO_DIR"
cd "$REPO_DIR"
if [ -f "$APP_DIR/.env.production" ]; then
cp "$APP_DIR/.env.production" "$REPO_DIR/.env.production"
fi
log "Building and deploying (first time)..."
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build --remove-orphans >> "$LOG_FILE" 2>&1
docker image prune -f >> "$LOG_FILE" 2>&1
log "First deploy completed. Commit: $(git log --oneline -1)"
exit 0
fi
cd "$REPO_DIR"
# Fetch latest
git fetch origin "$BRANCH" --quiet 2>/dev/null
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/$BRANCH)
# No changes
if [ "$LOCAL" = "$REMOTE" ]; then
exit 0
fi
# New commits detected - deploy
echo $$ > "$LOCK_FILE"
trap 'rm -f "$LOCK_FILE"' EXIT
log "=== New commits detected ==="
log "Local: $LOCAL"
log "Remote: $REMOTE"
git reset --hard origin/$BRANCH
log "Updated to: $(git log --oneline -1)"
if [ -f "$APP_DIR/.env.production" ]; then
cp "$APP_DIR/.env.production" "$REPO_DIR/.env.production"
fi
log "Building and deploying..."
docker compose -f docker-compose.prod.yml --env-file .env.production up -d --build --remove-orphans >> "$LOG_FILE" 2>&1
sleep 15
if curl -sf http://localhost/health > /dev/null 2>&1; then
log "Health check PASSED"
else
log "WARNING: Health check pending"
fi
docker image prune -f >> "$LOG_FILE" 2>&1
log "=== Deploy completed ==="
+89
View File
@@ -0,0 +1,89 @@
#!/bin/bash
set -euo pipefail
# ===========================================
# Re:Link IDC Server Setup Script
# Run once on the IDC server to initialize
# ===========================================
echo "=== Re:Link Server Setup ==="
# 1. Install Docker Compose V2 plugin
echo "[1/5] Installing Docker Compose V2 plugin..."
DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker}
mkdir -p "$DOCKER_CONFIG/cli-plugins"
COMPOSE_VERSION=$(curl -s https://api.github.com/repos/docker/compose/releases/latest | grep '"tag_name"' | sed -E 's/.*"([^"]+)".*/\1/')
curl -SL "https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-x86_64" -o "$DOCKER_CONFIG/cli-plugins/docker-compose"
chmod +x "$DOCKER_CONFIG/cli-plugins/docker-compose"
echo "Docker Compose $(docker compose version) installed."
# 2. Install Gitea Act Runner
echo "[2/5] Installing Gitea Act Runner..."
ACT_RUNNER_VERSION="0.2.11"
curl -SL "https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-amd64" -o /usr/local/bin/act_runner || {
# Fallback: download to home dir if no sudo
curl -SL "https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/act_runner-${ACT_RUNNER_VERSION}-linux-amd64" -o "$HOME/act_runner"
chmod +x "$HOME/act_runner"
echo "act_runner installed to $HOME/act_runner"
}
chmod +x /usr/local/bin/act_runner 2>/dev/null || true
echo "Act Runner v${ACT_RUNNER_VERSION} installed."
# 3. Create app directory
echo "[3/5] Creating application directory..."
APP_DIR="$HOME/relink"
mkdir -p "$APP_DIR"
cd "$APP_DIR"
# 4. Generate runner config
echo "[4/5] Generating runner config..."
act_runner generate-config > "$APP_DIR/runner-config.yaml" 2>/dev/null || "$HOME/act_runner" generate-config > "$APP_DIR/runner-config.yaml" 2>/dev/null || true
# 5. Create systemd service for act_runner
echo "[5/5] Creating systemd service..."
RUNNER_BIN=$(which act_runner 2>/dev/null || echo "$HOME/act_runner")
sudo tee /etc/systemd/system/gitea-runner.service > /dev/null << SERVICEEOF
[Unit]
Description=Gitea Act Runner
After=network.target docker.service
Requires=docker.service
[Service]
Type=simple
User=$(whoami)
WorkingDirectory=${APP_DIR}
ExecStart=${RUNNER_BIN} daemon --config ${APP_DIR}/runner-config.yaml
Restart=always
RestartSec=10
Environment=DOCKER_HOST=unix:///var/run/docker.sock
[Install]
WantedBy=multi-user.target
SERVICEEOF
echo ""
echo "=== Setup Complete ==="
echo ""
echo "Next steps:"
echo "1. Register the runner with Gitea:"
echo " cd $APP_DIR"
echo " act_runner register \\"
echo " --instance http://39.117.244.52:3000 \\"
echo " --token <REGISTRATION_TOKEN> \\"
echo " --name relink-runner \\"
echo " --labels ubuntu-latest:host"
echo ""
echo "2. Start the runner service:"
echo " sudo systemctl daemon-reload"
echo " sudo systemctl enable gitea-runner"
echo " sudo systemctl start gitea-runner"
echo ""
echo "3. Create .env.production in $APP_DIR:"
echo " cp .env.production.example .env.production"
echo " # Edit with real values"
echo ""
echo "4. Add secrets in Gitea repository settings:"
echo " - DB_USER, DB_PASSWORD, DB_NAME"
echo " - REDIS_PASSWORD"
echo " - NEXTAUTH_URL, NEXTAUTH_SECRET"
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
Re:Link Gitea Webhook Receiver
Listens for push events and triggers deployment.
"""
import hashlib
import hmac
import json
import os
import subprocess
import sys
from http.server import HTTPServer, BaseHTTPRequestHandler
from datetime import datetime
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "relink-deploy-secret")
DEPLOY_SCRIPT = os.path.expanduser("~/relink/deploy.sh")
DEPLOY_BRANCH = "main"
PORT = int(os.environ.get("WEBHOOK_PORT", "9000"))
LOG_FILE = os.path.expanduser("~/relink/webhook.log")
def log(msg: str):
line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}"
print(line, flush=True)
with open(LOG_FILE, "a") as f:
f.write(line + "\n")
def verify_signature(payload: bytes, signature: str) -> bool:
if not signature:
return False
expected = hmac.new(
WEBHOOK_SECRET.encode(), payload, hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)
class WebhookHandler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/deploy":
self.send_response(404)
self.end_headers()
return
content_length = int(self.headers.get("Content-Length", 0))
payload = self.rfile.read(content_length)
# Verify signature
signature = self.headers.get("X-Gitea-Signature", "")
if WEBHOOK_SECRET and not verify_signature(payload, signature):
log("REJECTED: Invalid signature")
self.send_response(403)
self.end_headers()
self.wfile.write(b"Invalid signature")
return
try:
data = json.loads(payload)
except json.JSONDecodeError:
self.send_response(400)
self.end_headers()
return
ref = data.get("ref", "")
if ref != f"refs/heads/{DEPLOY_BRANCH}":
log(f"SKIPPED: Push to {ref} (not {DEPLOY_BRANCH})")
self.send_response(200)
self.end_headers()
self.wfile.write(b"Skipped: not target branch")
return
pusher = data.get("pusher", {}).get("login", "unknown")
commits = len(data.get("commits", []))
log(f"DEPLOY TRIGGERED: {pusher} pushed {commits} commit(s) to {DEPLOY_BRANCH}")
# Run deploy script asynchronously
try:
subprocess.Popen(
["bash", DEPLOY_SCRIPT],
stdout=open(LOG_FILE, "a"),
stderr=subprocess.STDOUT,
start_new_session=True,
)
self.send_response(200)
self.end_headers()
self.wfile.write(b"Deploy started")
log("Deploy script launched")
except Exception as e:
log(f"ERROR: Failed to start deploy: {e}")
self.send_response(500)
self.end_headers()
self.wfile.write(f"Deploy failed: {e}".encode())
def do_GET(self):
if self.path == "/health":
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok")
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass # Suppress default logging
if __name__ == "__main__":
log(f"Webhook receiver starting on port {PORT}")
server = HTTPServer(("0.0.0.0", PORT), WebhookHandler)
try:
server.serve_forever()
except KeyboardInterrupt:
log("Webhook receiver stopped")
server.server_close()
+88
View File
@@ -0,0 +1,88 @@
services:
postgres:
image: postgis/postgis:16-3.4-alpine
restart: unless-stopped
ports:
- "127.0.0.1:5432:5432"
environment:
POSTGRES_USER: ${DB_USER:-relink}
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
POSTGRES_DB: ${DB_NAME:-relink_prod}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-relink} -d ${DB_NAME:-relink_prod}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- "127.0.0.1:6379:6379"
command: redis-server --requirepass ${REDIS_PASSWORD:?REDIS_PASSWORD is required}
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 5
web:
build:
context: .
dockerfile: Dockerfile
args:
APP_NAME: web
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
environment:
DATABASE_URL: postgresql://${DB_USER:-relink}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-relink_prod}?schema=public
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
NEXTAUTH_URL: ${NEXTAUTH_URL:-http://localhost:3000}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?NEXTAUTH_SECRET is required}
NODE_ENV: production
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
admin:
build:
context: .
dockerfile: Dockerfile
args:
APP_NAME: admin
restart: unless-stopped
ports:
- "127.0.0.1:3001:3000"
environment:
DATABASE_URL: postgresql://${DB_USER:-relink}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-relink_prod}?schema=public
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
NODE_ENV: production
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
nginx:
image: nginx:alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./deploy/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./deploy/nginx/ssl:/etc/nginx/ssl:ro
depends_on:
- web
- admin
volumes:
postgres_data:
redis_data: