Ver código fonte

P1 #7: Reverse Proxy (Caddy) + HTTPS

- Added Caddyfile with automatic HTTPS via Let's Encrypt
- Configured api.photoplaces.ru -> backend:8080
- Configured photoplaces.ru -> frontend:3000
- Configured minio.photoplaces.ru -> minio:9001 (with basic auth)
- Security headers: HSTS, CSP, X-Frame-Options, etc.
- Updated docker-compose.prod.yml to include Caddy service
- Removed direct port exposure for backend/frontend
- Updated env.prod.example with DOMAIN and MinIO console auth
- Updated deploy.sh to validate DOMAIN and check Caddy health
neyrogovnarik 2 meses atrás
pai
commit
62cc0f95e3
4 arquivos alterados com 150 adições e 27 exclusões
  1. 82 0
      deploy/Caddyfile
  2. 16 12
      deploy/deploy.sh
  3. 29 5
      deploy/docker-compose.prod.yml
  4. 23 10
      deploy/env.prod.example

+ 82 - 0
deploy/Caddyfile

@@ -0,0 +1,82 @@
+{
+	# Global options
+	admin off
+	http_port 80
+	https_port 443
+}
+
+# API subdomain
+api.{$DOMAIN} {
+	reverse_proxy backend:8080 {
+		header_up Host {host}
+		header_up X-Real-IP {remote}
+		header_up X-Forwarded-For {remote}
+		header_up X-Forwarded-Proto {scheme}
+	}
+
+	# Security headers
+	header {
+		Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
+		X-Content-Type-Options "nosniff"
+		X-Frame-Options "DENY"
+		Referrer-Policy "strict-origin-when-cross-origin"
+		Permissions-Policy "geolocation=(), microphone=(), camera=()"
+	}
+
+	# Rate limiting at proxy level (backup to app-level)
+	@auth {
+		path /api/v1/auth/*
+	}
+	rate_limit @auth {
+		zone auth
+		rate 10/minute
+		key {remote}
+	}
+}
+
+# Frontend main domain
+{$DOMAIN} {
+	reverse_proxy frontend:3000 {
+		header_up Host {host}
+		header_up X-Real-IP {remote}
+		header_up X-Forwarded-For {remote}
+		header_up X-Forwarded-Proto {scheme}
+	}
+
+	# Security headers
+	header {
+		Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
+		X-Content-Type-Options "nosniff"
+		X-Frame-Options "DENY"
+		Referrer-Policy "strict-origin-when-cross-origin"
+		Permissions-Policy "geolocation=(), microphone=(), camera=()"
+		Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' https://api-maps.yandex.ru; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' wss: https://api.{$DOMAIN} https://api-maps.yandex.ru; frame-ancestors 'none';"
+	}
+
+	# Static assets caching
+	@static {
+		path /_next/static/*
+		path /static/*
+		path /images/*
+		path /favicon.ico
+	}
+	header @static Cache-Control "public, max-age=31536000, immutable"
+
+	# Compression
+	encode zstd gzip
+}
+
+# MinIO console (optional, restricted)
+minio.{$DOMAIN} {
+	reverse_proxy minio:9001 {
+		header_up Host {host}
+		header_up X-Real-IP {remote}
+		header_up X-Forwarded-For {remote}
+		header_up X-Forwarded-Proto {scheme}
+	}
+
+	# Basic auth for MinIO console (add credentials to env)
+	basicauth {
+		{$MINIO_CONSOLE_USER} {$MINIO_CONSOLE_PASSWORD}
+	}
+}

+ 16 - 12
deploy/deploy.sh

@@ -73,7 +73,7 @@ if [ -f "$ENV_FILE" ]; then
 fi
 
 # 3. Validate required secrets
-required_vars=("DB_PASSWORD" "REDIS_PASSWORD" "JWT_SECRET" "JWT_REFRESH_SECRET" "S3_SECRET_KEY" "S3_PUBLIC_ENDPOINT" "NEXT_PUBLIC_API_URL" "ALLOWED_ORIGINS")
+required_vars=("DB_PASSWORD" "REDIS_PASSWORD" "JWT_SECRET" "JWT_REFRESH_SECRET" "S3_SECRET_KEY" "S3_PUBLIC_ENDPOINT" "NEXT_PUBLIC_API_URL" "ALLOWED_ORIGINS" "DOMAIN")
 missing=()
 for var in "${required_vars[@]}"; do
     if [ -z "${!var:-}" ]; then
@@ -105,22 +105,26 @@ create_docker_secrets
 echo ">>> Собираю и запускаю контейнеры..."
 docker compose -f deploy/docker-compose.prod.yml up -d --build
 
-# 5. Health check (migrations run in entrypoint)
-echo ">>> Проверяю здоровье (ждём миграции + старт)..."
-for i in $(seq 1 30); do
-    HEALTH=$(curl -sf http://localhost:8080/api/v1/health 2>/dev/null || echo '{"status":"fail"}')
-    if echo "$HEALTH" | grep -q '"ok"'; then
-        break
+# 6. Health check (Caddy -> backend -> migrations)
+echo ">>> Проверяю здоровье (ждём Caddy + миграции + старт)..."
+for i in $(seq 1 60); do
+    # Check Caddy health (port 2019 metrics endpoint)
+    if docker compose -f deploy/docker-compose.prod.yml exec -T caddy wget -q --spider http://localhost:2019/metrics 2>/dev/null; then
+        # Caddy is up, check backend through Caddy
+        HEALTH=$(curl -sf "https://${DOMAIN}/api/v1/health" 2>/dev/null || curl -sf http://localhost:8080/api/v1/health 2>/dev/null || echo '{"status":"fail"}')
+        if echo "$HEALTH" | grep -q '"ok"'; then
+            break
+        fi
     fi
-    if [ $i -eq 30 ]; then
+    if [ $i -eq 60 ]; then
         echo "✗ Таймаут ожидания здоровья API"
-        docker compose -f deploy/docker-compose.prod.yml logs --tail=30 backend
+        docker compose -f deploy/docker-compose.prod.yml logs --tail=30 caddy backend
         exit 1
     fi
     sleep 2
 done
 
 echo "✓ Деплой успешен!"
-echo "API:         ${NEXT_PUBLIC_API_URL}"
-echo "Фронтенд:    http://localhost:3000"
-echo "MinIO консоль: ${S3_PUBLIC_ENDPOINT}:9001"
+echo "Фронтенд:    https://${DOMAIN}"
+echo "API:         https://api.${DOMAIN}/api/v1"
+echo "MinIO консоль: https://minio.${DOMAIN} (basic auth)"

+ 29 - 5
deploy/docker-compose.prod.yml

@@ -19,6 +19,32 @@ x-secrets: &secrets
     external: true
 
 services:
+  caddy:
+    <<: *common
+    image: caddy:2.8-alpine
+    ports:
+      - "80:80"
+      - "443:443"
+    volumes:
+      - ./Caddyfile:/etc/caddy/Caddyfile
+      - caddy_data:/data
+      - caddy_config:/config
+    environment:
+      DOMAIN: ${DOMAIN}
+      MINIO_CONSOLE_USER: ${MINIO_CONSOLE_USER:-admin}
+      MINIO_CONSOLE_PASSWORD: ${MINIO_CONSOLE_PASSWORD}
+    depends_on:
+      backend:
+        condition: service_healthy
+      frontend:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD", "wget", "-q", "--spider", "http://localhost:2019/metrics"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
+      start_period: 10s
+
   postgres:
     <<: *common
     image: postgis/postgis:16-3.4-alpine
@@ -69,8 +95,6 @@ services:
     build:
       context: ./backend
       dockerfile: Dockerfile
-    ports:
-      - "127.0.0.1:8080:8080"
     env_file:
       - ./env.prod
     environment:
@@ -117,8 +141,6 @@ services:
         NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
         NEXT_PUBLIC_MAP_PROVIDER: yandex
         NEXT_PUBLIC_YANDEX_MAPS_API_KEY: ${YANDEX_MAPS_API_KEY}
-    ports:
-      - "127.0.0.1:3000:3000"
     depends_on:
       backend:
         condition: service_healthy
@@ -133,6 +155,8 @@ volumes:
   pgdata:
   redisdata:
   miniodata:
+  caddy_data:
+  caddy_config:
 
 networks:
   photoplaces:
@@ -147,4 +171,4 @@ secrets:
   jwt_refresh_secret:
     external: true
   s3_secret_key:
-    external: true
+    external: true

+ 23 - 10
deploy/env.prod.example

@@ -1,27 +1,40 @@
-# PostgreSQL
+# ===========================================
+# PhotoPlaces Production Environment Template
+# ===========================================
+# Copy to env.prod and fill in values
+# NEVER commit env.prod to git!
+
+# ----- Domain (REQUIRED for Caddy/HTTPS) -----
+DOMAIN=photoplaces.ru
+
+# ----- PostgreSQL -----
 DB_PASSWORD=
 
-# Redis
+# ----- Redis -----
 REDIS_PASSWORD=
 
-# JWT (generate: openssl rand -hex 32)
+# ----- JWT (generate: openssl rand -hex 32) -----
 JWT_SECRET=
 JWT_REFRESH_SECRET=
 
-# S3 / MinIO
+# ----- S3 / MinIO -----
 S3_ENDPOINT=http://minio:9000
-S3_PUBLIC_ENDPOINT=
+S3_PUBLIC_ENDPOINT=https://s3.photoplaces.ru
 S3_ACCESS_KEY=photoplaces
 S3_SECRET_KEY=
 
-# CloudPayments (optional, for payments)
+# ----- CloudPayments (optional, for payments) -----
 CLOUDPAYMENTS_PUBLIC_ID=
 CLOUDPAYMENTS_API_SECRET=
 
-# Frontend
-NEXT_PUBLIC_API_URL=
+# ----- Frontend -----
+NEXT_PUBLIC_API_URL=https://api.photoplaces.ru/api/v1
 NEXT_PUBLIC_MAP_PROVIDER=yandex
 YANDEX_MAPS_API_KEY=
 
-# CORS (comma-separated domains)
-ALLOWED_ORIGINS=
+# ----- CORS (comma-separated domains) -----
+ALLOWED_ORIGINS=https://photoplaces.ru,https://www.photoplaces.ru
+
+# ----- MinIO Console (optional, for Caddy basic auth) -----
+MINIO_CONSOLE_USER=admin
+MINIO_CONSOLE_PASSWORD=