diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..8ec48e1 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,295 @@ +name: Deploy + +on: + push: + branches: + - master + - dev + tags: + - 'v*' + release: + types: [published, created] + issue_comment: + types: [created] + workflow_dispatch: + inputs: + environment: + description: 'Environment to deploy' + required: true + type: choice + options: + - test + - master + - release + +permissions: + contents: read + pull-requests: read + +env: + REGISTRY: ghcr.io + IMAGE_NAME_BASE: universalscientifictechnologies/dosportal +jobs: + # Determine deployment environment + prepare: + runs-on: ubuntu-latest + outputs: + should_deploy: ${{ steps.check.outputs.should_deploy }} + environment: ${{ steps.check.outputs.environment }} + image_tag: ${{ steps.check.outputs.image_tag }} + app_dir: ${{ steps.check.outputs.app_dir }} + project_name: ${{ steps.check.outputs.project_name }} + steps: + - name: Check deployment conditions + id: check + env: + COMMENT_BODY: ${{ github.event.comment.body }} + run: | + SHOULD_DEPLOY="false" + ENVIRONMENT="" + IMAGE_TAG="" + APP_DIR="" + PROJECT_NAME="" + + # Release (tag or published release) + if [[ "${{ github.event_name }}" == "release" || ( "${{ github.event_name }}" == "push" && "${{ github.ref }}" == refs/tags/v* ) ]]; then + SHOULD_DEPLOY="true" + ENVIRONMENT="release" + if [[ "${{ github.event_name }}" == "release" ]]; then + IMAGE_TAG="${{ github.event.release.tag_name }}" + else + IMAGE_TAG="${GITHUB_REF#refs/tags/}" + fi + APP_DIR="/data/ust/dosportal/release" + PROJECT_NAME="dosportal-release" + + # Master branch (master or dev) + elif [[ "${{ github.event_name }}" == "push" && ( "${{ github.ref }}" == "refs/heads/master" || "${{ github.ref }}" == "refs/heads/dev" ) ]]; then + SHOULD_DEPLOY="true" + ENVIRONMENT="master" + IMAGE_TAG="master" + APP_DIR="/data/ust/dosportal/master" + PROJECT_NAME="dosportal-master" + + # PR comment with /test-me + elif [[ "${{ github.event_name }}" == "issue_comment" ]]; then + if [[ "$COMMENT_BODY" == *"/test-me"* ]] && [[ "${{ github.event.issue.pull_request }}" != "" ]]; then + SHOULD_DEPLOY="true" + ENVIRONMENT="test" + IMAGE_TAG="pr-${{ github.event.issue.number }}" + APP_DIR="/data/ust/dosportal/test" + PROJECT_NAME="dosportal-test" + fi + + # Manual workflow dispatch + elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + SHOULD_DEPLOY="true" + ENVIRONMENT="${{ inputs.environment }}" + if [[ "$ENVIRONMENT" == "release" ]]; then + IMAGE_TAG="latest" + APP_DIR="/data/ust/dosportal/release" + PROJECT_NAME="dosportal-release" + elif [[ "$ENVIRONMENT" == "master" ]]; then + IMAGE_TAG="master" + APP_DIR="/data/ust/dosportal/master" + PROJECT_NAME="dosportal-master" + elif [[ "$ENVIRONMENT" == "test" ]]; then + IMAGE_TAG="test" + APP_DIR="/data/ust/dosportal/test" + PROJECT_NAME="dosportal-test" + fi + fi + + echo "should_deploy=$SHOULD_DEPLOY" >> $GITHUB_OUTPUT + echo "environment=$ENVIRONMENT" >> $GITHUB_OUTPUT + echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT + echo "app_dir=$APP_DIR" >> $GITHUB_OUTPUT + echo "project_name=$PROJECT_NAME" >> $GITHUB_OUTPUT + + echo "Deployment decision:" + echo " Should deploy: $SHOULD_DEPLOY" + echo " Environment: $ENVIRONMENT" + echo " Image tag: $IMAGE_TAG" + + # Checkout PR code if comment trigger + checkout-pr: + runs-on: ubuntu-latest + needs: prepare + if: needs.prepare.outputs.should_deploy == 'true' && github.event_name == 'issue_comment' + steps: + - name: Get PR branch + id: pr + uses: actions/github-script@v7 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + core.setOutput('ref', pr.data.head.ref); + core.setOutput('sha', pr.data.head.sha); + + - name: Checkout PR + uses: actions/checkout@v6 + with: + ref: ${{ steps.pr.outputs.ref }} + + build-and-push: + runs-on: ubuntu-latest + needs: prepare + if: needs.prepare.outputs.should_deploy == 'true' + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - component: backend + dockerfile: backend.Dockerfile + - component: frontend + dockerfile: frontend.Dockerfile + + steps: + - name: Get PR info if needed + if: github.event_name == 'issue_comment' + id: pr_info + uses: actions/github-script@v7 + with: + script: | + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.issue.number + }); + core.setOutput('ref', pr.data.head.ref); + + - name: Checkout repository + uses: actions/checkout@v6 + with: + ref: ${{ github.event_name == 'issue_comment' && steps.pr_info.outputs.ref || github.ref }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BASE }}-${{ matrix.component }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=semver,pattern={{version}} + type=sha,prefix={{branch}}- + type=raw,value=${{ needs.prepare.outputs.image_tag }} + + - name: Build and push Docker image (${{ matrix.component }}) + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BASE }}-${{ matrix.component }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BASE }}-${{ matrix.component }}:buildcache,mode=max + + deploy: + name: Deploy to ${{ needs.prepare.outputs.environment }} + needs: [prepare, build-and-push] + if: needs.prepare.outputs.should_deploy == 'true' + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Read docker-compose file + id: compose_file + run: | + COMPOSE_CONTENT=$(cat docker-compose.yml | base64 -w 0) + echo "content=$COMPOSE_CONTENT" >> $GITHUB_OUTPUT + + - name: Deploy via SSH + uses: appleboy/ssh-action@v1.0.3 + env: + APP_DIR: ${{ needs.prepare.outputs.app_dir }} + ENV_FILE: ${{ needs.prepare.outputs.app_dir }}/env/${{ needs.prepare.outputs.environment }}.env + COMPOSE_FILE: docker-compose.yml + PROJECT_NAME: ${{ needs.prepare.outputs.project_name }} + REGISTRY: ${{ env.REGISTRY }} + IMAGE_NAME_BASE: ${{ env.IMAGE_NAME_BASE }} + IMAGE_TAG: ${{ needs.prepare.outputs.image_tag }} + COMPOSE_CONTENT: ${{ steps.compose_file.outputs.content }} + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + envs: APP_DIR,ENV_FILE,COMPOSE_FILE,PROJECT_NAME,REGISTRY,IMAGE_NAME_BASE,IMAGE_TAG,COMPOSE_CONTENT + script: | + set -euo pipefail + + echo "==> Deploying to environment: $PROJECT_NAME" + echo "==> Using image tag: $IMAGE_TAG" + + echo "==> Preparing app dir" + mkdir -p "$APP_DIR/env" + cd "$APP_DIR" + + echo "==> Creating docker-compose file" + echo "$COMPOSE_CONTENT" | base64 -d > "$COMPOSE_FILE" + + echo "==> Sanity checks" + test -f "$COMPOSE_FILE" || (echo "Missing compose file: $APP_DIR/$COMPOSE_FILE" && exit 1) + if [ ! -f "$ENV_FILE" ]; then + echo "Creating dummy env file at $ENV_FILE" + touch "$ENV_FILE" + fi + + docker version >/dev/null + docker compose version >/dev/null + + echo "==> Pull images (explicit, public GHCR)" + docker pull "$REGISTRY/$IMAGE_NAME_BASE-backend:$IMAGE_TAG" + docker pull "$REGISTRY/$IMAGE_NAME_BASE-frontend:$IMAGE_TAG" + + echo "==> Check for override file" + COMPOSE_ARGS="-f $COMPOSE_FILE" + if [ -f "docker-compose.override.yml" ]; then + echo "Found docker-compose.override.yml, applying it" + COMPOSE_ARGS="$COMPOSE_ARGS -f docker-compose.override.yml" + fi + + echo "==> Compose pull" + docker compose -p "$PROJECT_NAME" $COMPOSE_ARGS --env-file "$ENV_FILE" pull + + echo "==> Up (no downtime 'down' step)" + docker compose -p "$PROJECT_NAME" $COMPOSE_ARGS --env-file "$ENV_FILE" up -d --remove-orphans + + echo "==> Show status" + docker compose -p "$PROJECT_NAME" $COMPOSE_ARGS ps + + echo "==> Cleanup old images (keep recent ones)" + docker image prune -af --filter "until=168h" + + echo "==> Done" + + - name: Comment on PR (if test deployment) + if: needs.prepare.outputs.environment == 'test' && github.event_name == 'issue_comment' + uses: actions/github-script@v7 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: '✅ Test deployment completed! Available at: test.dosportal.ust.cz' + }) diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml new file mode 100644 index 0000000..88e543a --- /dev/null +++ b/.github/workflows/dev_deploy.yml @@ -0,0 +1,166 @@ +on: + push: + branches: + - dev + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME_BASE: universalscientifictechnologies/dosportal + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - component: backend + dockerfile: backend.Dockerfile + - component: frontend + dockerfile: frontend.Dockerfile + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to the Container registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BASE }}-${{ matrix.component }} + tags: | + type=ref,event=branch + type=sha,prefix={{branch}}- + + - name: Build and push Docker image (${{ matrix.component }}) + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ matrix.dockerfile }} + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BASE }}-${{ matrix.component }}:buildcache + cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME_BASE }}-${{ matrix.component }}:buildcache,mode=max + + deploy: + name: Deploy (dev) + needs: build-and-push + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Read docker-compose file + id: compose_file + run: | + COMPOSE_CONTENT=$(cat docker-compose.yml | base64 -w 0) + echo "content=$COMPOSE_CONTENT" >> $GITHUB_OUTPUT + + - name: Debug and Clean Secrets + id: clean_secrets + run: | + # Clean whitespace from secrets + CLEAN_HOST=$(echo "${{ secrets.DEPLOY_HOST }}" | xargs) + CLEAN_USER=$(echo "${{ secrets.DEPLOY_USER }}" | xargs) + + echo "Original HOST length: ${#CLEAN_HOST}" + echo "Original USER length: ${#CLEAN_USER}" + + # Check for common issues + if [[ "$CLEAN_HOST" == http* ]]; then + echo "::warning::DEPLOY_HOST starts with http/https - stripping it" + CLEAN_HOST=$(echo "$CLEAN_HOST" | sed 's|^https*://||g') + fi + + # Mask parts for debug + echo "HOST last char: ${CLEAN_HOST: -1}" + echo "HOST last octet: ...${CLEAN_HOST##*.}" + + # Save cleaned secrets to output + echo "host=$CLEAN_HOST" >> $GITHUB_OUTPUT + echo "user=$CLEAN_USER" >> $GITHUB_OUTPUT + + # Setup SSH Key + echo "${{ secrets.DEPLOY_SSH_KEY }}" > /tmp/ssh_key + chmod 600 /tmp/ssh_key + + - name: Test SSH connection + run: | + HOST="${{ steps.clean_secrets.outputs.host }}" + USER="${{ steps.clean_secrets.outputs.user }}" + + echo "Testing connection to $USER@$HOST" + ssh -vvv -i /tmp/ssh_key -o StrictHostKeyChecking=no -o ConnectTimeout=10 "$USER@$HOST" "echo 'SSH connection successful'" || echo "SSH failed" + + - name: Deploy to dev server via SSH + uses: appleboy/ssh-action@v1.0.3 + env: + APP_DIR: /data/ust/dosportal/master + ENV_FILE: /data/ust/dosportal/master/env/dev.env + COMPOSE_FILE: docker-compose.yml + PROJECT_NAME: dosportal-dev + REGISTRY: ${{ env.REGISTRY }} + IMAGE_NAME_BASE: ${{ env.IMAGE_NAME_BASE }} + IMAGE_TAG: dev + COMPOSE_CONTENT: ${{ steps.compose_file.outputs.content }} + with: + host: ${{ steps.clean_secrets.outputs.host }} + username: ${{ steps.clean_secrets.outputs.user }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + envs: APP_DIR,ENV_FILE,COMPOSE_FILE,PROJECT_NAME,REGISTRY,IMAGE_NAME_BASE,IMAGE_TAG,COMPOSE_CONTENT + script: | + set -euo pipefail + + echo "==> Preparing app dir" + mkdir -p "$APP_DIR/env" + cd "$APP_DIR" + + echo "==> Creating docker-compose file" + echo "$COMPOSE_CONTENT" | base64 -d > "$COMPOSE_FILE" + + echo "==> Sanity checks" + test -f "$COMPOSE_FILE" || (echo "Missing compose file: $APP_DIR/$COMPOSE_FILE" && exit 1) + # Create dummy env file if missing (for first run) + if [ ! -f "$ENV_FILE" ]; then + echo "Creating dummy env file at $ENV_FILE" + touch "$ENV_FILE" + fi + + docker version >/dev/null + docker compose version >/dev/null + + echo "==> Pull images (explicit, public GHCR)" + docker pull "$REGISTRY/$IMAGE_NAME_BASE-backend:$IMAGE_TAG" + docker pull "$REGISTRY/$IMAGE_NAME_BASE-frontend:$IMAGE_TAG" + + echo "==> Compose pull (recommended if compose references images)" + docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$ENV_FILE" pull + + echo "==> Up (no downtime 'down' step)" + docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --remove-orphans + + echo "==> Show status" + docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps + + echo "==> Cleanup old images (keep recent ones)" + docker image prune -af --filter "until=168h" + + echo "==> Done" + diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..073ae92 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,107 @@ +networks: + inet: + driver: bridge + ipam: + config: + - subnet: 10.5.0.0/16 + gateway: 10.5.0.1 + default: + driver: bridge + ipam: + driver: default + traefik: + external: true + +services: + db_dosportal: + image: postgis/postgis + env_file: + - .env + volumes: + - ./data/dosportal/db_pg:/var/lib/postgresql/data + environment: + - POSTGRES_DB=${POSTGRES_DB:-dosportal} + - POSTGRES_USER=${POSTGRES_USER:-dosportal_user} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-dosportal_password} + networks: + inet: + ipv4_address: 10.5.0.5 + + backend: + image: ghcr.io/universalscientifictechnologies/dosportal-backend:dev + env_file: + - .env + volumes: + - .:/DOSPORTAL + depends_on: + - db_dosportal + - redis + - minio + networks: + - inet + - traefik + labels: + - "traefik.enable=true" + - "traefik.http.routers.dosportal-dev-backend.rule=Host(`dev.dosportal.ust.cz`) && PathPrefix(`/api`)" + - "traefik.http.routers.dosportal-dev-backend.entrypoints=websecure" + - "traefik.http.routers.dosportal-dev-backend.tls.certresolver=letsencrypt" + - "traefik.http.services.dosportal-dev-backend.loadbalancer.server.port=8000" + + worker: + image: ghcr.io/universalscientifictechnologies/dosportal-backend:dev + entrypoint: python3 manage.py qcluster + env_file: + - .env + volumes: + - .:/DOSPORTAL + depends_on: + - backend + - db_dosportal + - redis + networks: + inet: + ipv4_address: 10.5.0.8 + + redis: + image: redis + volumes: + - .:/DOSPORTAL + depends_on: + - db_dosportal + networks: + inet: + ipv4_address: 10.5.0.7 + + minio: + image: minio/minio + env_file: + - .env + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} + command: server /data --console-address ":9001" + volumes: + - ./data/minio:/data + networks: + - inet + - traefik + labels: + - "traefik.enable=true" + - "traefik.http.routers.dosportal-dev-minio.rule=Host(`dev.dosportal.ust.cz`) && PathPrefix(`/minio`)" + - "traefik.http.routers.dosportal-dev-minio.entrypoints=websecure" + - "traefik.http.routers.dosportal-dev-minio.tls.certresolver=letsencrypt" + - "traefik.http.services.dosportal-dev-minio.loadbalancer.server.port=9000" + + frontend: + image: ghcr.io/universalscientifictechnologies/dosportal-frontend:dev + depends_on: + - backend + networks: + - inet + - traefik + labels: + - "traefik.enable=true" + - "traefik.http.routers.dosportal-dev-frontend.rule=Host(`dev.dosportal.ust.cz`)" + - "traefik.http.routers.dosportal-dev-frontend.entrypoints=websecure" + - "traefik.http.routers.dosportal-dev-frontend.tls.certresolver=letsencrypt" + - "traefik.http.services.dosportal-dev-frontend.loadbalancer.server.port=5173" diff --git a/docker-compose.yml b/docker-compose.yml index 58e5c39..b2c6d88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,28 +1,8 @@ networks: - inet: + dosportal_internal: driver: bridge - ipam: - config: - - subnet: 10.5.0.0/16 - gateway: 10.5.0.1 - default: - driver: bridge - ipam: - driver: default services: - # db_dosportal: - # image: postgres - # volumes: - # - ./data/dosportal/db:/var/lib/postgresql/data - # environment: - # - POSTGRES_DB=dosportal - # - POSTGRES_USER=dosportal_user - # - POSTGRES_PASSWORD=dosportal_password - # networks: - # inet: - # ipv4_address: 10.5.0.5 - db_dosportal: image: postgis/postgis env_file: @@ -34,64 +14,42 @@ services: - POSTGRES_USER=${POSTGRES_USER:-dosportal_user} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-dosportal_password} networks: - inet: - ipv4_address: 10.5.0.5 + - dosportal_internal backend: - build: - context: . - dockerfile: backend.Dockerfile - network: host - # platform: linux/amd64 - # command: python manage.py runserver 0.0.0.0:8000 - # entrypoint: python3 manage.py runserver 0.0.0.0:8000 + image: ghcr.io/universalscientifictechnologies/dosportal-backend:dev env_file: - .env - volumes: - - .:/DOSPORTAL - ports: - - "8100:8000" depends_on: - db_dosportal - redis - minio networks: - inet: - ipv4_address: 10.5.0.6 - # default: - + - dosportal_internal + worker: - build: - context: . - dockerfile: backend.Dockerfile + image: ghcr.io/universalscientifictechnologies/dosportal-backend:dev entrypoint: python3 manage.py qcluster - volumes: - - .:/DOSPORTAL + env_file: + - .env depends_on: - backend - db_dosportal - redis networks: - inet: - ipv4_address: 10.5.0.8 + - dosportal_internal redis: image: redis - volumes: - - .:/DOSPORTAL depends_on: - db_dosportal networks: - inet: - ipv4_address: 10.5.0.7 + - dosportal_internal minio: image: minio/minio env_file: - .env - ports: - - "9000:9000" - - "9001:9001" environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin} @@ -99,27 +57,23 @@ services: volumes: - ./data/minio:/data networks: - inet: - ipv4_address: 10.5.0.9 + - dosportal_internal frontend: - build: - context: . - dockerfile: frontend.Dockerfile - ports: - - "5173:5173" + image: ghcr.io/universalscientifictechnologies/dosportal-frontend:dev + depends_on: + - backend + networks: + - dosportal_internal + + nginx: + image: nginx:alpine volumes: - - ./frontend:/app/frontend - - /app/frontend/node_modules - environment: - - NODE_ENV=development + - ./nginx_config/default.conf:/etc/nginx/conf.d/default.conf depends_on: + - frontend - backend + ports: + - "8080:80" networks: - inet: - ipv4_address: 10.5.0.10 - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:5173"] - interval: 10s - timeout: 5s - retries: 5 + - dosportal_internal diff --git a/nginx_config/default.conf b/nginx_config/default.conf new file mode 100644 index 0000000..765eed4 --- /dev/null +++ b/nginx_config/default.conf @@ -0,0 +1,60 @@ +server { + listen 80; + server_name _; + + # Redirect /admin to /admin/ so it matches the block below + location = /admin { + return 301 /admin/; + } + + # Django admin + location /admin/ { + proxy_pass http://backend:8000; + 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; + } + + # Django static files + location /static/ { + proxy_pass http://backend:8000; + 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; + } + + # Backend API + location /api/ { + proxy_pass http://backend:8000; + 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; + } + + # Minio + location /minio/ { + rewrite ^/minio/(.*)$ /$1 break; + proxy_pass http://minio:9000; + 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; + } + + # Frontend (catch all) + location / { + proxy_pass http://frontend:5173; + 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; + + # WebSocket support + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +}