diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6673000 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# Shell scripts must use LF — CRLF breaks Alpine entrypoints ("No such file or directory"). +*.sh text eol=lf diff --git a/.gitea/workflows/registry-build-deploy.yml b/.gitea/workflows/registry-build-deploy.yml index 1ca249e..211f33a 100644 --- a/.gitea/workflows/registry-build-deploy.yml +++ b/.gitea/workflows/registry-build-deploy.yml @@ -1,30 +1,32 @@ -# Build backend/frontend images, push to Gitea Container Registry, deploy with pull-only compose. +# Staging: build backend/frontend images, push to Gitea Container Registry, deploy on self-hosted runner. # -# Repository Variables (Settings → Actions → Variables) — non-secret: -# REGISTRY_HOST e.g. 178.131.50.201:3000 (no http/https) -# REGISTRY_OWNER Gitea user or org that owns the packages (same as image namespace) -# PUBLIC_BASE_URL URL users open in browser, e.g. http://178.131.50.201:8088 (no trailing slash) +# Triggers: push to master/main, or manual workflow_dispatch. +# +# Repository Variables (Settings → Actions → Variables): +# REGISTRY_HOST Docker registry host:port (no http/https). Same PC as Gitea → 127.0.0.1:3000 +# REGISTRY_OWNER Gitea user or org that owns the packages +# PUBLIC_BASE_URL URL users sopen in browser, e.g. http://178.131.50.201:8088 (no trailing slash) +# DEPLOY_SECRETS_DIR absolute path on runner, e.g. C:/dyolink/secrets +# +# Optional: +# STAGING_HTTP_PORT host port for nginx (default 8088) +# CLONE_HOST git clone host when runner = Gitea host → 127.0.0.1:3000 +# +# Same Windows PC runs Gitea + runner + deploy: +# CLONE_HOST → 127.0.0.1:3000 (git runs on Windows host) +# REGISTRY_HOST → host.docker.internal:3000 (docker commands run inside Docker Desktop VM) +# Gitea app.ini ROOT_URL → http://host.docker.internal:3000/ +# PUBLIC_BASE_URL → public IP:8088 (browser URL for staging app) +# +# Add host.docker.internal:3000 to Docker Desktop insecure-registries. # # Repository Secrets (Settings → Actions → Secrets): # REGISTRY_USERNAME Gitea username for docker login # REGISTRY_PASSWORD Gitea access token (packages:read/write) or account password # -# HTTP registry (typical self-hosted Gitea): Docker defaults to HTTPS. If login/push fails with -# "server gave HTTP response to HTTPS client", add REGISTRY_HOST (e.g. 192.168.1.100:3000) to the -# Docker daemon "insecure-registries" on the RUNNER machine, then restart Docker (Docker Desktop -# → Settings → Docker Engine → JSON → "insecure-registries": ["host:port"]). +# Docker on runner: add registry hosts to insecure-registries, e.g. ["127.0.0.1:3000","178.131.50.201:3000"] # -# Optional: -# STAGING_HTTP_PORT host port for nginx (default 8088) -# -# Required for deploy job (absolute path on the runner host): -# DEPLOY_SECRETS_DIR folder containing database.staging.env + backend.staging.env -# -# Runner: self-hosted with Docker. Default shell is powershell (Windows act_runner often has no WSL bash). -# For a Linux runner, change defaults.run.shell to bash and restore bash syntax if needed. -# -# We do NOT use gitea.com/actions/checkout — many restricted networks cannot reach gitea.com. -# Checkout is a plain git clone from the same Gitea host. +# Runner: self-hosted with Docker + git. Default shell is powershell (Windows act_runner). name: Registry — build, push, deploy @@ -38,189 +40,149 @@ defaults: shell: powershell jobs: - temp-success: - runs-on: self-hosted + build-and-push: + runs-on: windows + outputs: + image_tag: ${{ steps.meta.outputs.image_tag }} steps: - - name: Temporary placeholder (always success) + - name: Checkout (clone from this Gitea — no gitea.com) run: | $ErrorActionPreference = 'Stop' - Write-Host "Temporary workflow is active." - Write-Host "Trigger: ${{ github.event_name }}" - Write-Host "Branch: ${{ github.ref_name }}" - Write-Host "Commit: ${{ github.sha }}" - Write-Host "Production build/push/deploy steps are intentionally commented." - exit 0 + $cloneHost = '${{ vars.CLONE_HOST }}'.Trim() + if ([string]::IsNullOrWhiteSpace($cloneHost)) { + $Server = "${{ github.server_url }}".TrimEnd('/') + } elseif ($cloneHost -match '^https?://') { + $Server = $cloneHost.TrimEnd('/') + } else { + $Server = 'http://' + $cloneHost + } + $Repo = "${{ github.repository }}" + $Branch = "${{ github.ref_name }}" + $Token = "${{ github.token }}" + $Actor = "${{ github.actor }}" + $hp = $Server -replace '^https?://', '' + if ($Server.StartsWith('https')) { + $cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git' + } else { + $cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git' + } + $env:GIT_TERMINAL_PROMPT = '0' + git clone --depth 1 --branch $Branch $cloneUrl . - # --------------------------------------------------------------------------- - # Production pipeline is temporarily disabled. - # Uncomment these jobs after split-DNS / registry reachability is fixed. - # --------------------------------------------------------------------------- - # - # build-and-push: - # runs-on: self-hosted - # outputs: - # image_tag: ${{ steps.meta.outputs.image_tag }} - # steps: - # - name: Checkout (clone from this Gitea — no gitea.com) - # - name: Image tag and registry prefix - # - name: Log in to container registry - # - name: Build and push backend - # - name: Build and push frontend - # - # deploy: - # needs: build-and-push - # runs-on: self-hosted - # steps: - # - name: Checkout (shallow clone from this Gitea — no gitea.com) - # - name: Write deploy.registry.env and validate secrets path - # - name: Log in to container registry (for pull) - # - name: Pull and start stack + - name: Image tag and registry prefix + id: meta + run: | + $ErrorActionPreference = 'Stop' + $short = (git rev-parse --short HEAD).Trim() + $utf8 = New-Object System.Text.UTF8Encoding $false + [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "image_tag=$short`n", $utf8) + $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}" + [System.IO.File]::AppendAllText($env:GITHUB_ENV, "REGISTRY_PREFIX=$prefix`n", $utf8) + - name: Log in to container registry + run: | + $ErrorActionPreference = 'Stop' + $pass = @' + ${{ secrets.REGISTRY_PASSWORD }} + '@ + $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + - name: Build and push backend + run: | + $ErrorActionPreference = 'Stop' + $tag = "${{ steps.meta.outputs.image_tag }}" + docker build ` + -t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" ` + -t "$env:REGISTRY_PREFIX/dyolink-backend:latest" ` + ./backend + docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag" + docker push "$env:REGISTRY_PREFIX/dyolink-backend:latest" - ####SAMPLE - # name: Registry — build, push, deploy + - name: Build and push frontend + env: + PUBLIC_BASE_URL: ${{ vars.PUBLIC_BASE_URL }} + run: | + $ErrorActionPreference = 'Stop' + $tag = "${{ steps.meta.outputs.image_tag }}" + $base = $env:PUBLIC_BASE_URL + docker build ` + --build-arg "NEXT_PUBLIC_API_URL=$base/api" ` + --build-arg "NEXT_PUBLIC_APP_URL=$base" ` + --build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" ` + -t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" ` + -t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" ` + ./frontend + docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" + docker push "$env:REGISTRY_PREFIX/dyolink-frontend:latest" - # on: - # push: - # branches: [main, master] - # workflow_dispatch: + deploy: + needs: build-and-push + runs-on: windows + steps: + - name: Checkout (shallow clone from this Gitea — no gitea.com) + run: | + $ErrorActionPreference = 'Stop' + $cloneHost = '${{ vars.CLONE_HOST }}'.Trim() + if ([string]::IsNullOrWhiteSpace($cloneHost)) { + $Server = "${{ github.server_url }}".TrimEnd('/') + } elseif ($cloneHost -match '^https?://') { + $Server = $cloneHost.TrimEnd('/') + } else { + $Server = 'http://' + $cloneHost + } + $Repo = "${{ github.repository }}" + $Branch = "${{ github.ref_name }}" + $Token = "${{ github.token }}" + $Actor = "${{ github.actor }}" + $hp = $Server -replace '^https?://', '' + if ($Server.StartsWith('https')) { + $cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git' + } else { + $cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git' + } + $env:GIT_TERMINAL_PROMPT = '0' + git clone --depth 1 --branch $Branch $cloneUrl . - # defaults: - # run: - # shell: powershell + - name: Write deploy.registry.env and validate secrets path + run: | + $ErrorActionPreference = 'Stop' + $SD = '${{ vars.DEPLOY_SECRETS_DIR }}'.Trim() + if ([string]::IsNullOrWhiteSpace($SD)) { + Write-Host "Set repository variable DEPLOY_SECRETS_DIR to the absolute path on this runner" + Write-Host "where database.staging.env and backend.staging.env live (not in git)." + exit 1 + } + if (-not (Test-Path (Join-Path $SD "database.staging.env"))) { + Write-Host "Missing $(Join-Path $SD 'database.staging.env')" + exit 1 + } + if (-not (Test-Path (Join-Path $SD "backend.staging.env"))) { + Write-Host "Missing $(Join-Path $SD 'backend.staging.env')" + exit 1 + } + $stagingPort = '${{ vars.STAGING_HTTP_PORT }}'.Trim() + if ([string]::IsNullOrWhiteSpace($stagingPort)) { $stagingPort = '8088' } + $imageTag = "${{ needs.build-and-push.outputs.image_tag }}" + $lines = @( + "REGISTRY_PREFIX=${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}", + "IMAGE_TAG=$imageTag", + "STAGING_HTTP_PORT=$stagingPort", + "DEPLOY_SECRETS_DIR=$SD" + ) + Set-Location infrastructure + $lines | Set-Content -Path deploy.registry.env -Encoding utf8 - # jobs: - # build-and-push: - # runs-on: self-hosted - # outputs: - # image_tag: ${{ steps.meta.outputs.image_tag }} - # steps: - # - name: Checkout (clone from this Gitea — no gitea.com) - # run: | - # $ErrorActionPreference = 'Stop' - # $Server = "${{ github.server_url }}".TrimEnd('/') - # $Repo = "${{ github.repository }}" - # $Branch = "${{ github.ref_name }}" - # $Token = "${{ github.token }}" - # $Actor = "${{ github.actor }}" - # $hp = $Server -replace '^https?://', '' - # if ($Server.StartsWith('https')) { - # $cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git' - # } else { - # $cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git' - # } - # $env:GIT_TERMINAL_PROMPT = '0' - # git clone --depth 1 --branch $Branch $cloneUrl . - - # - name: Image tag and registry prefix - # id: meta - # run: | - # $ErrorActionPreference = 'Stop' - # $short = (git rev-parse --short HEAD).Trim() - # $utf8 = New-Object System.Text.UTF8Encoding $false - # [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "image_tag=$short`n", $utf8) - # $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}" - # [System.IO.File]::AppendAllText($env:GITHUB_ENV, "REGISTRY_PREFIX=$prefix`n", $utf8) - - # - name: Log in to container registry - # run: | - # $ErrorActionPreference = 'Stop' - # $pass = @' - # ${{ secrets.REGISTRY_PASSWORD }} - # '@ - # $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin - - # - name: Build and push backend - # run: | - # $ErrorActionPreference = 'Stop' - # $tag = "${{ steps.meta.outputs.image_tag }}" - # docker build ` - # -t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" ` - # -t "$env:REGISTRY_PREFIX/dyolink-backend:latest" ` - # ./backend - # docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag" - # docker push "$env:REGISTRY_PREFIX/dyolink-backend:latest" - - # - name: Build and push frontend - # env: - # PUBLIC_BASE_URL: ${{ vars.PUBLIC_BASE_URL }} - # run: | - # $ErrorActionPreference = 'Stop' - # $tag = "${{ steps.meta.outputs.image_tag }}" - # $base = $env:PUBLIC_BASE_URL - # docker build ` - # --build-arg "NEXT_PUBLIC_API_URL=$base/api" ` - # --build-arg "NEXT_PUBLIC_APP_URL=$base" ` - # --build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" ` - # -t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" ` - # -t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" ` - # ./frontend - # docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" - # docker push "$env:REGISTRY_PREFIX/dyolink-frontend:latest" - - # deploy: - # needs: build-and-push - # runs-on: self-hosted - # steps: - # - name: Checkout (shallow clone from this Gitea — no gitea.com) - # run: | - # $ErrorActionPreference = 'Stop' - # $Server = "${{ github.server_url }}".TrimEnd('/') - # $Repo = "${{ github.repository }}" - # $Branch = "${{ github.ref_name }}" - # $Token = "${{ github.token }}" - # $Actor = "${{ github.actor }}" - # $hp = $Server -replace '^https?://', '' - # if ($Server.StartsWith('https')) { - # $cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git' - # } else { - # $cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git' - # } - # $env:GIT_TERMINAL_PROMPT = '0' - # git clone --depth 1 --branch $Branch $cloneUrl . - - # - name: Write deploy.registry.env and validate secrets path - # run: | - # $ErrorActionPreference = 'Stop' - # $SD = '${{ vars.DEPLOY_SECRETS_DIR }}'.Trim() - # if ([string]::IsNullOrWhiteSpace($SD)) { - # Write-Host "Set repository variable DEPLOY_SECRETS_DIR to the absolute path on this runner" - # Write-Host "where database.staging.env and backend.staging.env live (not in git)." - # exit 1 - # } - # if (-not (Test-Path (Join-Path $SD "database.staging.env"))) { - # Write-Host "Missing $(Join-Path $SD 'database.staging.env')" - # exit 1 - # } - # if (-not (Test-Path (Join-Path $SD "backend.staging.env"))) { - # Write-Host "Missing $(Join-Path $SD 'backend.staging.env')" - # exit 1 - # } - # $stagingPort = '${{ vars.STAGING_HTTP_PORT }}'.Trim() - # if ([string]::IsNullOrWhiteSpace($stagingPort)) { $stagingPort = '8088' } - # $imageTag = "${{ needs.build-and-push.outputs.image_tag }}" - # $lines = @( - # "REGISTRY_PREFIX=${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}", - # "IMAGE_TAG=$imageTag", - # "STAGING_HTTP_PORT=$stagingPort", - # "DEPLOY_SECRETS_DIR=$SD" - # ) - # Set-Location infrastructure - # $lines | Set-Content -Path deploy.registry.env -Encoding utf8 - - # - name: Log in to container registry (for pull) - # run: | - # $ErrorActionPreference = 'Stop' - # $pass = @' - # ${{ secrets.REGISTRY_PASSWORD }} - # '@ - # $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin - - # - name: Pull and start stack - # run: | - # $ErrorActionPreference = 'Stop' - # Set-Location infrastructure - # docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend - # docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d + - name: Log in to container registry (for pull) + run: | + $ErrorActionPreference = 'Stop' + $pass = @' + ${{ secrets.REGISTRY_PASSWORD }} + '@ + $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin + - name: Pull and start stack + run: | + $ErrorActionPreference = 'Stop' + Set-Location infrastructure + docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend + docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d diff --git a/AGENTS.md b/AGENTS.md index ee313a0..8236b0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Dental clinic ↔ lab platform (monorepo): |------|--------| | `backend/` | NestJS, Prisma, PostgreSQL | | `frontend/` | Next.js 16, React 19, next-intl, Tailwind | -| `infrastructure/` | Docker, nginx, deploy scripts | +| `infrastructure/` | Docker, nginx, deploy scripts — prod: `DEPLOY.md`, staging: `STAGING-DEPLOY.md` | **Organization types:** `CLINIC` (patients, appointments, treatment) and `LAB` (cases, tasks). Many features are org-type-specific. Permissions use `TAB_*_READ` / `TAB_*_EDIT` codes — see `backend/src/common/permissions.ts`. diff --git a/README.md b/README.md index a412280..2302c2f 100644 --- a/README.md +++ b/README.md @@ -42,103 +42,13 @@ SSL is issued automatically via **Certbot** (`scripts/init-letsencrypt.sh`). Ngi --- -## Deploy on your own server (Docker + Gitea) +## Staging deploy (Gitea Actions + Windows) -High level: **build container images → push to a registry → server pulls images and runs Compose**. Optionally **Gitea Actions** automates that on every merge to `main` / `master`. +**Full guide:** [`infrastructure/STAGING-DEPLOY.md`](infrastructure/STAGING-DEPLOY.md) -### 1. One-time server preparation +Merge or push to **`master`** → Gitea Actions builds images → deploys to `http://:8088`. -1. Install **Docker** and **Docker Compose** on the server. -2. Run **Gitea** with the **container registry** enabled (same host/port you use for `docker login`, e.g. `178.131.50.201:3000`). -3. Copy the repo (or deploy only `infrastructure/` + secrets). You need at least: - - - `infrastructure/docker-compose.registry.yml` - - `infrastructure/nginx/` configs referenced by that compose file - - `infrastructure/database/init.sql` if used by your Postgres service - -4. **Secrets on the server** (never commit real values): - - - Copy `infrastructure/database.staging.env.example` → **`database.staging.env`** (Postgres user/password/db). - - Copy `infrastructure/backend.staging.env.example` → **`backend.staging.env`** (e.g. `DATABASE_URL`, JWT, pointing at the compose Postgres service name). - - Put both files in one directory on the server, e.g. `/opt/dyolink/secrets/`. - -5. **Registry login from the server** (same credentials you use for `docker push`): - - ```bash - docker login : -u - ``` - - For HTTP registries, Docker may require **`insecure-registries`** on the daemon. - -### 2. Manual deploy (build images elsewhere, run on server) - -On your **dev machine** (after successful local builds): - -```powershell -$REG = ":" -$OWN = "" -$TAG = "manual" - -docker build -t "${REG}/${OWN}/dyolink-backend:${TAG}" -t "${REG}/${OWN}/dyolink-backend:latest" ./backend - -docker build ` - --build-arg NEXT_PUBLIC_API_URL="http://:/api" ` - --build-arg NEXT_PUBLIC_APP_URL="http://:" ` - --build-arg NEXT_PUBLIC_APP_NAME="Dyolink" ` - -t "${REG}/${OWN}/dyolink-frontend:${TAG}" ` - -t "${REG}/${OWN}/dyolink-frontend:latest" ` - ./frontend - -docker push "${REG}/${OWN}/dyolink-backend:${TAG}" -docker push "${REG}/${OWN}/dyolink-backend:latest" -docker push "${REG}/${OWN}/dyolink-frontend:${TAG}" -docker push "${REG}/${OWN}/dyolink-frontend:latest" -``` - -On the **server**, from `infrastructure/`: - -1. Create **`deploy.registry.env`** (see `infrastructure/deploy.registry.env.example`): - - - `REGISTRY_PREFIX=:/` (no `http://`, no trailing slash) - - `IMAGE_TAG=latest` or the tag you pushed - - `STAGING_HTTP_PORT=` (e.g. `8088` — browser uses `http://:8088`) - -2. Set **`DEPLOY_SECRETS_DIR`** to the absolute path of the folder containing `database.staging.env` and `backend.staging.env` (you can export it in the shell or add it to `deploy.registry.env` if your Compose setup expects it). - -3. Pull and start: - - ```bash - docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend - docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d - ``` - -The backend container runs **`prisma migrate deploy`** on startup (via entrypoint) when `NODE_ENV=production`, so schema updates apply after you deploy a new image that includes new migrations. - -### 3. Automatic deploy (Gitea Actions) - -Workflow file: **`.gitea/workflows/registry-build-deploy.yml`**. - -**Requirements:** - -- **Gitea Actions** enabled for the repository. -- A **self-hosted runner** (with Docker) registered to Gitea — the workflow uses `runs-on: self-hosted`. -- **Windows runners:** the workflow uses **PowerShell** (not Bash). Gitea’s runner was failing with `execvpe(/bin/bash) failed` when Bash was routed through WSL without a real `/bin/bash`. If your runner is **Linux**, switch `.gitea/workflows/registry-build-deploy.yml` to `defaults.run.shell: bash` and use Bash syntax instead. -- **Repository → Actions → Variables** (examples): - - - `REGISTRY_HOST` — e.g. `178.131.50.201:3000` - - `REGISTRY_OWNER` — image namespace (same as Docker image path after the host), e.g. `admin` - - `PUBLIC_BASE_URL` — URL users open in the browser, e.g. `http://178.131.50.201:8088` (no trailing slash) - - `DEPLOY_SECRETS_DIR` — **absolute path on the runner machine** to the folder containing `database.staging.env` and `backend.staging.env` - - Optional: `STAGING_HTTP_PORT` (defaults to `8088`) - -- **Repository → Actions → Secrets:** - - - `REGISTRY_USERNAME` - - `REGISTRY_PASSWORD` — access token with package read/write (or equivalent) - -**Trigger:** push to **`main`** or **`master`**, or run the workflow manually (**workflow_dispatch**). - -The pipeline clones from your Gitea instance, builds and pushes backend/frontend images, then on the runner runs **`docker compose pull`** and **`up -d`** using `infrastructure/docker-compose.registry.yml`. +Workflow: [`.gitea/workflows/registry-build-deploy.yml`](.gitea/workflows/registry-build-deploy.yml) --- @@ -148,5 +58,6 @@ The pipeline clones from your Gitea instance, builds and pushes backend/frontend |------|------| | `backend/Dockerfile` | API image | | `frontend/Dockerfile` | Web image | +| `infrastructure/STAGING-DEPLOY.md` | Staging setup, CI variables, testing | | `infrastructure/docker-compose.registry.yml` | Pull-only staging stack (registry images + nginx + postgres) | | `infrastructure/deploy.registry.env.example` | Template for `deploy.registry.env` | diff --git a/backend/Dockerfile b/backend/Dockerfile index c7f3ec0..f381da1 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -47,7 +47,8 @@ COPY --from=builder /app/dist ./dist COPY --from=builder /app/node_modules ./node_modules COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -RUN chmod +x /usr/local/bin/docker-entrypoint.sh +# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory"). +RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh RUN mkdir -p /app/logs && \ chown -R dyolink:nodejs /app diff --git a/backend/prisma/migrations/20260714194721/migration.sql b/backend/prisma/migrations/20260714194721/migration.sql new file mode 100644 index 0000000..17e0f5f --- /dev/null +++ b/backend/prisma/migrations/20260714194721/migration.sql @@ -0,0 +1,2 @@ +-- DropIndex +DROP INDEX "lab_cases_dueDate_idx"; diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 378b2a3..62f4fb9 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -41,19 +41,19 @@ ENV NODE_ENV=production ENV PORT=3000 ENV HOSTNAME=0.0.0.0 -COPY --from=builder /app/public ./public COPY --from=builder --chown=dyolink:nodejs /app/.next/standalone ./ COPY --from=builder --chown=dyolink:nodejs /app/.next/static ./.next/static COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh -RUN chmod +x /usr/local/bin/docker-entrypoint.sh +# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory"). +RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh USER dyolink EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ - CMD node -e "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})" + CMD node -e "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))" ENTRYPOINT ["dumb-init", "--", "docker-entrypoint.sh"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 31dfa4d..3428e26 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -81,7 +81,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -2300,7 +2299,6 @@ "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2373,7 +2371,6 @@ "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.57.0", "@typescript-eslint/types": "8.57.0", @@ -2899,7 +2896,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3203,7 +3199,6 @@ "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/types": "^7.26.0" } @@ -3281,7 +3276,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4083,7 +4077,6 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4269,7 +4262,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -6318,6 +6310,17 @@ } } }, + "node_modules/next-intl/node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz", @@ -6774,7 +6777,6 @@ "resolved": "https://registry.npmmirror.com/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6784,7 +6786,6 @@ "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6797,7 +6798,6 @@ "resolved": "https://registry.npmmirror.com/react-hook-form/-/react-hook-form-7.71.2.tgz", "integrity": "sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" }, @@ -6813,8 +6813,7 @@ "version": "16.13.1", "resolved": "https://registry.npmmirror.com/react-is/-/react-is-16.13.1.tgz", "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-qr-code": { "version": "2.2.0", @@ -6834,7 +6833,6 @@ "resolved": "https://registry.npmmirror.com/react-redux/-/react-redux-9.3.0.tgz", "integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -6887,8 +6885,7 @@ "version": "5.0.1", "resolved": "https://registry.npmmirror.com/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -7660,7 +7657,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7823,7 +7819,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8189,7 +8184,6 @@ "resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz", "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/infrastructure/STAGING-DEPLOY.md b/infrastructure/STAGING-DEPLOY.md new file mode 100644 index 0000000..3b51cee --- /dev/null +++ b/infrastructure/STAGING-DEPLOY.md @@ -0,0 +1,316 @@ +# Dyolink — Staging deploy (Gitea + Windows) + +Automatic staging on a **self-hosted Gitea** machine: merge (or push) to **`master`** → build Docker images → push to Gitea Container Registry → deploy on the same host. + +**Example:** Gitea at `http://178.131.50.201:3000`, staging app at `http://178.131.50.201:8088`. + +Production (`nudentic.ir` on Linux, tag-based releases) is documented in [`DEPLOY.md`](DEPLOY.md). + +--- + +## Architecture + +``` +Push / merge to master + ↓ +Gitea Actions (self-hosted act_runner on Windows) + ↓ +Build backend + frontend → push to Gitea registry + ↓ +docker compose pull + up -d (docker-compose.registry.yml) + ↓ +http://:8088 → nginx → frontend / backend → postgres +``` + +| Service | Image source | +|----------|---------------------------------------------------| +| postgres | `postgres:15-alpine` (pulled from Docker Hub) | +| backend | `//dyolink-backend:` | +| frontend | `//dyolink-frontend:` | +| nginx | `nginx:alpine` | + +Frontend public URLs are **baked in at build time** via `PUBLIC_BASE_URL`. + +--- + +## Triggers + +| Event | Staging deploy? | +|-------|-----------------| +| Push to `master` | Yes | +| PR merged into `master` | Yes (merge = push to `master`) | +| Push to other branches only | No | +| Git tag (e.g. `v1.0.0`) | No — reserved for production later | +| Manual | Gitea → Actions → **Registry — build, push, deploy** → Run workflow | + +Workflow file: [`.gitea/workflows/registry-build-deploy.yml`](../.gitea/workflows/registry-build-deploy.yml) + +--- + +## One-time setup (Windows host) + +### 1. Docker Desktop + +- Install and keep **Docker Desktop running** during builds/deploys. +- **Settings → Docker Engine** — allow HTTP registry: + +```json +{ + "insecure-registries": [ + "host.docker.internal:3000", + "127.0.0.1:3000", + "178.131.50.201:3000", + "192.168.1.100:3000" + ] +} +``` + +Apply & restart Docker Desktop. + +**Why `host.docker.internal`?** Docker Desktop runs in a Linux VM. `docker login` runs from that VM — `127.0.0.1:3000` is the VM, not Gitea on Windows. + +### 2b. Gitea `app.ini` — match registry URL + +Edit `C:\gitea\custom\conf\app.ini`: + +```ini +[server] +ROOT_URL = http://host.docker.internal:3000/ +``` + +Restart Gitea. Gitea uses `ROOT_URL` for Docker registry auth redirects. + +### 2c. Gitea Actions runner + +Download [act_runner](https://gitea.com/gitea/act_runner/releases) → e.g. `C:\gitea-runner\act_runner.exe`. + +**Important — runner level:** the registration token decides which repos can use the runner. + +| Token from | Runner level | Works for `admin/dyolink`? | +|------------|--------------|----------------------------| +| **Site Administration → Actions → Runners** | Instance (global) | Yes (recommended) | +| **Repo → Settings → Actions → Runners** | Repository | Yes | +| **User profile → Settings → Actions → Runners** | Individual | Often **no** — jobs stay queued with “no matching online runner” | + +If your runner shows **Type: Individual** in Gitea but jobs never start, delete it and re-register with an **instance** or **repository** token (table above). + +Register (use **localhost** when Gitea runs on the same PC — public IP often fails locally): + +```powershell +cd C:\gitea-runner +# Stop daemon first (Ctrl+C) if running +.\act_runner.exe register ` + --instance "http://127.0.0.1:3000" ` + --token "" ` + --name "windows-staging" ` + --labels "windows:host" +``` + +Start (leave running, or install as a Windows service later): + +```powershell +.\act_runner.exe daemon +``` + +**Verify:** open **your repo** → **Settings → Actions → Runners** — `windows-staging` must appear here as **Idle/Online** (not only under user settings). + +**Requires:** `git` on PATH (for workflow checkout step). + +### 3. Secret env files (not in git) + +```powershell +New-Item -ItemType Directory -Force -Path "C:\dyolink\secrets" +``` + +Copy examples and edit: + +- `infrastructure/database.staging.env.example` → `C:\dyolink\secrets\database.staging.env` +- `infrastructure/backend.staging.env.example` → `C:\dyolink\secrets\backend.staging.env` + +Rules: + +- `DATABASE_URL` password must match `POSTGRES_PASSWORD`. +- `FRONTEND_URL` must match `PUBLIC_BASE_URL` (e.g. `http://178.131.50.201:8088`). +- Replace JWT secrets with long random values (not `CHANGE_ME`). + +### 4. Gitea repository Variables + +**Settings → Actions → Variables** + +| Name | Example | Notes | +|------|---------|--------| +| `REGISTRY_HOST` | `host.docker.internal:3000` | **Windows + Docker Desktop:** Docker runs in a Linux VM — `127.0.0.1` is the VM, not Gitea. Use `host.docker.internal:3000`. Also set Gitea `ROOT_URL` to match (see below). | +| `REGISTRY_OWNER` | `admin` | Gitea user/org owning packages | +| `PUBLIC_BASE_URL` | `http://178.131.50.201:8088` | How **users** open staging in a browser (public IP OK) | +| `DEPLOY_SECRETS_DIR` | `C:/dyolink/secrets` | Forward slashes OK on Windows | +| `CLONE_HOST` | `127.0.0.1:3000` | Git clone (runs on Windows host, not inside Docker VM) | +| `STAGING_HTTP_PORT` | `8088` | Optional (8088 is default) | + +**Naming note:** Gitea rejects variable names starting with `GITEA_` or `GITHUB_`. Use `CLONE_HOST`, not `GITEA_CLONE_URL`. + +### 5. Gitea repository Secrets + +**Settings → Actions → Secrets** + +| Name | Value | +|------|--------| +| `REGISTRY_USERNAME` | Gitea username | +| `REGISTRY_PASSWORD` | Gitea access token with **package read/write** | + +Create token: profile → **Settings → Applications → Generate New Token**. + +### 6. Firewall (once) + +```powershell +New-NetFirewallRule -DisplayName "Dyolink Staging 8088" -Direction Inbound -Protocol TCP -LocalPort 8088 -Action Allow +``` + +### 7. External access on Windows + Docker Desktop (portproxy) + +Gitea on **:3000** runs natively on Windows and is reachable from your Mac. Staging **:8088** runs in **Docker Desktop** — `127.0.0.1:8088` works on the PC, but `http://:8088` from another machine may get **Empty reply from server** unless you forward the port. + +Compose binds nginx to **127.0.0.1:8088** only. After deploy, run **once** in **PowerShell as Administrator**: + +```powershell +netsh interface portproxy add v4tov4 listenaddress=0.0.0.0 listenport=8088 connectaddress=127.0.0.1 connectport=8088 +netsh interface portproxy show all +``` + +Verify on the server: + +```powershell +curl http://127.0.0.1:8088/health +``` + +From your Mac: + +```bash +curl http://178.131.50.201:8088/health +``` + +If the public IP still fails but LAN works, add **router port forward 8088** → Windows PC (same as Gitea **3000**). + +To remove portproxy later: + +```powershell +netsh interface portproxy delete v4tov4 listenaddress=0.0.0.0 listenport=8088 +``` + +--- + +## Test the pipeline + +### Before first run + +- [ ] Docker Desktop running +- [ ] `act_runner.exe daemon` running +- [ ] All Variables + Secrets set (including `CLONE_HOST`) +- [ ] Secret env files exist under `DEPLOY_SECRETS_DIR` +- [ ] Workflow enabled on `master` (see repo) + +### Option A — Manual workflow (safest first test) + +1. Gitea → repo → **Actions** +2. **Registry — build, push, deploy** → **Run workflow** → branch `master` +3. Watch jobs: **build-and-push** → **deploy** (first run ~15–30 min) + +### Option B — Push to master + +```bash +git push origin master +``` + +Or merge a PR into `master` — same result. + +### Verify success + +**On Windows (PowerShell):** + +```powershell +docker ps +``` + +Expect: `dyolink_nginx_staging`, `dyolink_backend_staging`, `dyolink_frontend_staging`, `dyolink_postgres_staging`. + +**From browser or another machine:** + +```text +http://178.131.50.201:8088 +``` + +**Health check:** + +```powershell +curl http://178.131.50.201:8088/api/health +``` + +Expected: `{"status":"ok",...}` + +**Gitea packages:** profile/org → **Packages** — should list `dyolink-backend` and `dyolink-frontend` after first build. + +--- + +## Manual deploy (without CI) + +Useful when debugging registry/compose without re-running the full workflow. + +On the Windows host, from repo `infrastructure/`: + +1. Create `deploy.registry.env` from [`deploy.registry.env.example`](deploy.registry.env.example) +2. Set `REGISTRY_PREFIX`, `IMAGE_TAG`, `STAGING_HTTP_PORT`, `DEPLOY_SECRETS_DIR` +3. `docker login 178.131.50.201:3000 -u ` +4. `docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend` +5. `docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d` + +--- + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `no matching online runner with label` | Runner **offline** → start `act_runner.exe daemon`. Or wrong **runner level** → re-register with token from **Site Administration → Actions → Runners** or **repo → Settings → Actions → Runners** (not user profile). Confirm runner appears on **repo** Runners page as Online. | +| Runner can't register on public IP | Use `http://127.0.0.1:3000` for `--instance` | +| Variable name rejected in Gitea | No `GITEA_*` / `GITHUB_*` prefixes; use `CLONE_HOST` | +| `docker login` connection refused on `127.0.0.1:3000` | **Docker Desktop on Windows:** set `REGISTRY_HOST=host.docker.internal:3000`, add it to insecure-registries, set Gitea `ROOT_URL=http://host.docker.internal:3000/`. Keep `CLONE_HOST=127.0.0.1:3000` for git. | +| `docker login` / push denied, redirect to public IP | Set Gitea `ROOT_URL` to a host Docker can reach (`host.docker.internal:3000` on Windows Docker Desktop). | +| `server gave HTTP response to HTTPS client` | Add registry host to Docker **insecure-registries**, restart Docker | +| `Missing database.staging.env` | Check `DEPLOY_SECRETS_DIR` path and file names | +| `docker login` denied | Token needs package permissions; check username/secret | +| Git clone fails in workflow | Set `CLONE_HOST=127.0.0.1:3000` | +| Port 8088 unreachable from Mac / empty reply | Docker Desktop: run **portproxy** (§7). `127.0.0.1:8088/health` must work on Windows first. | +| Backend restart loop | JWT secrets still placeholder; fix `backend.staging.env` | +| Backend DB auth error | `DATABASE_URL` password ≠ `POSTGRES_PASSWORD` | +| `dumb-init docker-entrypoint.sh: No such file or directory` | Windows CRLF in shell scripts — fixed in Dockerfiles (rebuild images). | +| `frontend is unhealthy` / deploy waits on frontend | Next.js `/` redirects to `/en` (3xx). Rebuild after healthcheck fix (accepts 2xx/3xx). | + +**Logs:** + +```powershell +docker logs dyolink_backend_staging --tail 50 +docker logs dyolink_nginx_staging --tail 50 +docker logs dyolink_frontend_staging --tail 50 +``` + +--- + +## File reference + +| Path | Role | +|------|------| +| `.gitea/workflows/registry-build-deploy.yml` | CI: build, push, deploy | +| `infrastructure/docker-compose.registry.yml` | Staging stack (pull-only images) | +| `infrastructure/deploy.registry.env.example` | Manual deploy env template | +| `infrastructure/database.staging.env.example` | Postgres secrets template | +| `infrastructure/backend.staging.env.example` | API secrets template | +| `infrastructure/nginx/http-only.conf` | HTTP reverse proxy for staging | + +--- + +## Production (later) + +| Environment | Trigger | Host | +|-------------|---------|------| +| Staging | Push/merge to `master` | Windows + Gitea | +| Production | Git tag `v*.*.*` | Linux + `nudentic.ir` | + +Production flow will use Docker Hub (or registry) + [`DEPLOY.md`](DEPLOY.md) — not yet wired to the same workflow. diff --git a/infrastructure/deploy.registry.env.example b/infrastructure/deploy.registry.env.example index 4e8eb3b..87f1add 100644 --- a/infrastructure/deploy.registry.env.example +++ b/infrastructure/deploy.registry.env.example @@ -15,5 +15,8 @@ IMAGE_TAG=latest STAGING_HTTP_PORT=8088 # Absolute path on the server where database.staging.env and backend.staging.env live. -# Use forward slashes on Windows. Same variable as Gitea Actions → DEPLOY_SECRETS_DIR. -# DEPLOY_SECRETS_DIR=D:/dyolink/secrets +# Use forward slashes on Windows. Same path as Gitea Actions variable DEPLOY_SECRETS_DIR. +# DEPLOY_SECRETS_DIR=C:/dyolink/secrets +# +# Gitea Actions also needs CLONE_HOST=127.0.0.1:3000 when runner and Gitea share one Windows host. +# See STAGING-DEPLOY.md (do not use GITEA_* variable names — Gitea rejects them). diff --git a/infrastructure/docker-compose.registry.yml b/infrastructure/docker-compose.registry.yml index acc2caf..2603d4f 100644 --- a/infrastructure/docker-compose.registry.yml +++ b/infrastructure/docker-compose.registry.yml @@ -75,7 +75,8 @@ services: - dyolink_staging restart: unless-stopped healthcheck: - test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"] + # Root redirects to /en (next-intl) — accept 2xx/3xx as healthy. + test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))"] interval: 30s timeout: 10s retries: 3 @@ -85,10 +86,13 @@ services: image: nginx:alpine container_name: dyolink_nginx_staging depends_on: - - backend - - frontend + backend: + condition: service_healthy + frontend: + condition: service_healthy ports: - - "${STAGING_HTTP_PORT:-8088}:80" + # Localhost only — Docker Desktop on Windows often breaks 0.0.0.0; use netsh portproxy for LAN/public (see STAGING-DEPLOY.md). + - "127.0.0.1:${STAGING_HTTP_PORT:-8088}:80" volumes: - ./nginx/http-only.conf:/etc/nginx/conf.d/default.conf:ro - ./logs/nginx-staging:/var/log/nginx diff --git a/infrastructure/docker-compose.staging.yml b/infrastructure/docker-compose.staging.yml index 81e7ef4..54a5102 100644 --- a/infrastructure/docker-compose.staging.yml +++ b/infrastructure/docker-compose.staging.yml @@ -77,7 +77,8 @@ services: - dyolink_staging restart: unless-stopped healthcheck: - test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"] + # Root redirects to /en (next-intl) — accept 2xx/3xx as healthy. + test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => { process.exit(r.statusCode >= 200 && r.statusCode < 400 ? 0 : 1); }).on('error', () => process.exit(1))"] interval: 30s timeout: 10s retries: 3 @@ -87,10 +88,12 @@ services: image: nginx:alpine container_name: dyolink_nginx_staging depends_on: - - backend - - frontend + backend: + condition: service_healthy + frontend: + condition: service_healthy ports: - - "${STAGING_HTTP_PORT:-8088}:80" + - "127.0.0.1:${STAGING_HTTP_PORT:-8088}:80" volumes: - ./nginx/http-only.conf:/etc/nginx/conf.d/default.conf:ro - ./logs/nginx-staging:/var/log/nginx diff --git a/infrastructure/nginx/http-only.conf b/infrastructure/nginx/http-only.conf index 133c685..9594e0b 100644 --- a/infrastructure/nginx/http-only.conf +++ b/infrastructure/nginx/http-only.conf @@ -1,15 +1,10 @@ # HTTP only — local dev and IP-based staging (no TLS). # Use with: docker compose and map host port e.g. 8080:80 or 8088:80 +# +# Dynamic proxy_pass via Docker DNS (127.0.0.11) so nginx starts even if +# backend/frontend containers are not up yet (static upstream blocks fail at boot). -upstream dyolink_backend { - server backend:3000; - keepalive 32; -} - -upstream dyolink_frontend { - server frontend:3000; - keepalive 32; -} +resolver 127.0.0.11 valid=10s ipv6=off; server { listen 80; @@ -19,7 +14,8 @@ server { client_max_body_size 50M; location / { - proxy_pass http://dyolink_frontend; + set $frontend_upstream http://frontend:3000; + proxy_pass $frontend_upstream; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; @@ -33,7 +29,8 @@ server { } location /api { - proxy_pass http://dyolink_backend; + set $backend_upstream http://backend:3000; + proxy_pass $backend_upstream; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade';