diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2aa51af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +node_modules +.next +.git +.gitignore +*.md +docker-compose*.yml +Dockerfile +.dockerignore +.env* +!.env.production.example +coverage +.turbo +dist +.omc diff --git a/.env.production.example b/.env.production.example new file mode 100644 index 0000000..3d8adab --- /dev/null +++ b/.env.production.example @@ -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 diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..afeea8a --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c824621 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/apps/admin/next.config.ts b/apps/admin/next.config.ts index d4b523b..0b761bb 100644 --- a/apps/admin/next.config.ts +++ b/apps/admin/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { + output: 'standalone', transpilePackages: ['@relink/ui', '@relink/shared'], }; diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index d4b523b..0b761bb 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -1,6 +1,7 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { + output: 'standalone', transpilePackages: ['@relink/ui', '@relink/shared'], }; diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..26ac841 --- /dev/null +++ b/deploy/deploy.sh @@ -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 ===" diff --git a/deploy/nginx/default.conf b/deploy/nginx/default.conf new file mode 100644 index 0000000..be0a56b --- /dev/null +++ b/deploy/nginx/default.conf @@ -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; + } +} diff --git a/deploy/poll-deploy.sh b/deploy/poll-deploy.sh new file mode 100644 index 0000000..f6a0200 --- /dev/null +++ b/deploy/poll-deploy.sh @@ -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 ===" diff --git a/deploy/setup-server.sh b/deploy/setup-server.sh new file mode 100644 index 0000000..213cdc6 --- /dev/null +++ b/deploy/setup-server.sh @@ -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 \\" +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" diff --git a/deploy/webhook-receiver.py b/deploy/webhook-receiver.py new file mode 100644 index 0000000..5ee25ae --- /dev/null +++ b/deploy/webhook-receiver.py @@ -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() diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..84b98b9 --- /dev/null +++ b/docker-compose.prod.yml @@ -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: