Compare commits

...

11 Commits

Author SHA1 Message Date
6d49fd0b89 Bind staging nginx to localhost for Windows Docker Desktop
Publish port 8088 on 127.0.0.1 only so netsh portproxy can expose staging
to LAN/public IPs; document the one-time portproxy setup on Windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 17:35:54 +03:30
e1c0434ed1 Fix frontend staging healthcheck for locale redirect
Accept 2xx/3xx on / so next-intl redirect to /en no longer marks the
container unhealthy and blocks nginx from starting.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 16:34:58 +03:30
eff27e359b Fix staging containers failing on Windows Docker builds
Strip CRLF from entrypoint scripts in backend/frontend images, use Docker
DNS in staging nginx config, and wait for healthy app services before nginx.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 16:16:14 +03:30
2b2cca8361 fix docker url for making connection with gitea. 2026-08-23 15:20:14 +03:30
0e8e3a4a27 local ip set instead of static public ip server to find it in network. 2026-08-23 14:13:29 +03:30
a43c433d55 lable self-hosted change to windows 2026-08-23 11:34:10 +03:30
8b462ea45a Merge branch 'master' into feature/ci-cd 2026-08-23 11:11:27 +03:30
ebd2de39cf staging deploy md doc. 2026-08-23 11:11:07 +03:30
1258ecf899 Enable Gitea Actions staging deploy on master push 2026-08-23 11:10:28 +03:30
b779a57cba remove ./public from dockerfile. 2026-08-21 14:58:21 +03:30
e6c236f1a0 package-locks and prisma migration update. 2026-08-20 11:30:14 +03:30
13 changed files with 528 additions and 333 deletions

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
# Shell scripts must use LF — CRLF breaks Alpine entrypoints ("No such file or directory").
*.sh text eol=lf

View File

@@ -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)
# 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 open 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

View File

@@ -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`.

View File

@@ -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://<host>: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 <registry-host>:<port> -u <user>
```
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 = "<registry-host>:<port>"
$OWN = "<registry-owner>"
$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://<your-public-ip>:<nginx-port>/api" `
--build-arg NEXT_PUBLIC_APP_URL="http://<your-public-ip>:<nginx-port>" `
--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=<host>:<port>/<owner>` (no `http://`, no trailing slash)
- `IMAGE_TAG=latest` or the tag you pushed
- `STAGING_HTTP_PORT=<host port>` (e.g. `8088` — browser uses `http://<ip>: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). Giteas 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` |

View File

@@ -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

View File

@@ -0,0 +1,2 @@
-- DropIndex
DROP INDEX "lab_cases_dueDate_idx";

View File

@@ -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"]

View File

@@ -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"
}

View File

@@ -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://<host-ip>:8088 → nginx → frontend / backend → postgres
```
| Service | Image source |
|----------|---------------------------------------------------|
| postgres | `postgres:15-alpine` (pulled from Docker Hub) |
| backend | `<REGISTRY_HOST>/<owner>/dyolink-backend:<sha>` |
| frontend | `<REGISTRY_HOST>/<owner>/dyolink-frontend:<sha>` |
| 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 "<token-from-Site-Admin-OR-repo-Runners-page>" `
--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://<public-ip>: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 ~1530 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 <user>`
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.

View File

@@ -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).

View File

@@ -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

View File

@@ -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

View File

@@ -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';