From 32480ae7850ccb3c2e8ff1d4dd14ca10a61fe5c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Ib=C3=A1=C3=B1ez?= Date: Tue, 25 Aug 2026 09:59:00 +0200 Subject: [PATCH 1/5] feat: one-file deploy on Dokploy, without a second copy of the API env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dokploy loads exactly one compose file, so the `-f base -f overlay` pattern the rest of the repo uses is unavailable, and three things a Dokploy host needs cannot be expressed from its UI: `web` must publish no host port (Traefik reaches it over `dokploy-network`, and a published port both bypasses the host firewall via DNAT and collides with whatever already holds 3000), every routed service must join that external network, and TLS terminating at Traefik means COOKIE_SECURE has to flip — the same reason docker-compose.caddy.yml flips it. `db` and `api` are pulled in with `extends` rather than copied. That env block is ~60 keys and most of this repo's deployment bugs have been one of them failing to reach the container; a second hand-maintained copy would drift, and the symptom would be "what I configured in Dokploy does nothing". `web` is written out in full instead, because the one thing it must not inherit is `ports:` and Compose merges sequences rather than replacing them. Service names are prefixed, since a Dokploy host shows every project's containers side by side and `api`/`db`/`web` say nothing there. The old names survive as network aliases: `docker/nginx.conf` is baked into the web image and proxies to `api:8000`, and the inherited DATABASE_URL points at `db:5432`, so without the aliases a cosmetic rename would force edits to files the Caddy, cloudflared and dev deployments also use. `depends_on` needs `!override` for the same rename — `extends` carries it over pointing at the base file's names, and re-declaring it merges instead of replacing, leaving a dangling reference that compose refuses to start. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017NQQDUWt5Ga5xTqvjGuSpL --- docker-compose.dokploy.yml | 166 +++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 docker-compose.dokploy.yml diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml new file mode 100644 index 00000000..c97c8d87 --- /dev/null +++ b/docker-compose.dokploy.yml @@ -0,0 +1,166 @@ +# SkillNet — Docker Compose for Dokploy (https://dokploy.com) +# +# In Dokploy: create a project -> "Compose" -> Provider: Git (this repo) -> +# Compose Path: `docker-compose.dokploy.yml` -> put your settings in the +# Environment tab -> Deploy. +# +# ## Why this file exists instead of just pointing Dokploy at docker-compose.yml +# +# Dokploy loads exactly ONE compose file, so the `-f base -f overlay` pattern the rest of +# this repo uses (see docker-compose.caddy.yml) is not available. Three things have to +# change for a Dokploy host, and none of them can be expressed from the UI alone: +# +# 1. `web` must NOT publish a host port. Dokploy's Traefik reaches it over the shared +# `dokploy-network`, and a published port would both bypass the host firewall (Docker +# publishes via DNAT) and collide with whatever else on the box already holds 3000. +# 2. Every routed service must join the external `dokploy-network`, or Traefik cannot +# see it and the domain 404s. +# 3. TLS terminates at Traefik, so `COOKIE_SECURE` has to flip to true — same reason +# docker-compose.caddy.yml flips it. +# +# ## Why `extends:` and not a copy of the base file +# +# `api`'s environment block is ~60 keys, and this repo's history is mostly bugs where one +# of them failed to reach the container. A second hand-maintained copy would drift, and +# the symptom would be "the feature I configured in Dokploy does nothing". `extends` keeps +# docker-compose.yml the single source of truth for what `db` and `api` are; this file only +# states the deltas. +# +# One sharp edge: `extends` DOES carry `depends_on` over, pointing at the base file's +# service names. Since those names change here, the inherited value is a dangling +# reference (`depends on undefined service "db"`, and compose refuses to start) — and +# re-declaring it would MERGE with the inherited one, not replace it. Hence the +# `!override` tag: https://docs.docker.com/reference/compose-file/merge/#override +# +# ## Why the services are renamed, and why they keep network aliases +# +# `db` / `api` / `web` are fine inside a project that owns the whole daemon, but a Dokploy +# host runs every project's containers side by side and the names show up in its UI, in +# `docker ps` and in Traefik's dashboard. So they are `skillnet-postgres`, `skillnet-api` +# and `skillnet-web` here. +# +# The `aliases:` below are NOT decoration — the old names are hardcoded in two places this +# file does not own: +# +# * `docker/nginx.conf` proxies /api/, /ext/ and /health to `http://api:8000`. That file +# is baked into the web image and shared with every other compose overlay in the repo. +# * `DATABASE_URL` is inherited from docker-compose.yml and points at `@db:5432`. +# +# An alias makes both names resolve to the same container, so the rename stays cosmetic +# instead of turning into an edit of files the non-Dokploy deployments also use. +# +# ## Not included +# +# The `a2a`, `mcp` and `api-fixtures` services stay out: they are profile-gated in the base +# file and Dokploy gives no place to pass `--profile`. Add them here explicitly (same +# `extends` shape, minus `profiles`) if you want them on this host. + +services: + # ── Database ──────────────────────────────────────────────── + # Not on dokploy-network and no published port: nothing outside this compose project + # should be able to reach PostgreSQL. `api` finds it on the project's default network. + skillnet-postgres: + extends: + file: docker-compose.yml + service: db + networks: + default: + # The inherited DATABASE_URL says `db:5432`. Keep that name resolvable. + aliases: + - db + + # ── Backend API ───────────────────────────────────────────── + # Also internal-only. The SPA talks to it through nginx (`web` proxies /api/, /ext/ and + # /health to api:8000, see docker/nginx.conf), so it needs no domain of its own. + skillnet-api: + extends: + file: docker-compose.yml + service: api + networks: + default: + # nginx inside the web image proxies to `api:8000` — see docker/nginx.conf. + aliases: + - api + environment: + # Traefik terminates TLS in front of the whole stack, so session cookies can and + # should be HTTPS-only. The base file defaults this to `false` so a plain-HTTP + # localhost run works; here there is always TLS. + COOKIE_SECURE: ${COOKIE_SECURE:-true} + # The SPA is served from the same origin it calls, so this matters only for external + # API clients — but the base default (`http://localhost:3000`) is wrong for a + # deployed host, and a wrong default here is a browser error with no server log. + CORS_ORIGINS: ${CORS_ORIGINS:-["https://${DOMAIN}"]} + depends_on: !override + skillnet-postgres: + condition: service_healthy + + # ── Frontend (nginx + React SPA) ──────────────────────────── + # Written out in full rather than extended, because the one thing it inherits that must + # not survive is `ports:` — and Compose MERGES sequences like `ports` instead of + # replacing them (the same trap documented at length in docker-compose.caddy.yml). + skillnet-web: + build: + context: . + dockerfile: docker/web.Dockerfile + restart: unless-stopped + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + networks: + # `default` has to be listed explicitly: naming any network opts a service out of + # the implicit default, and without it nginx could not resolve the API at all. + default: {} + dokploy-network: {} + labels: + - traefik.enable=true + # Which network Traefik should use to reach this container. Omitting it makes + # Traefik pick one of the two at random and half the requests time out. + - traefik.docker.network=dokploy-network + # Router names are global to the Dokploy instance's Traefik, not scoped to this + # project — change `skillnet` here if you deploy SkillNet twice on one host. + - traefik.http.routers.skillnet.rule=Host(`${DOMAIN:?Set DOMAIN in the Dokploy Environment tab to the public hostname}`) + - traefik.http.routers.skillnet.entrypoints=websecure + # `letsencrypt` is the resolver Dokploy's own Traefik ships with; certificates are + # issued and renewed by Dokploy, so there is no Caddy/ACME state to keep here. + - traefik.http.routers.skillnet.tls.certresolver=letsencrypt + # The container listens on 80 (nginx). Traefik cannot infer this when a service + # exposes no published port, which is exactly our case. + - traefik.http.services.skillnet.loadbalancer.server.port=80 + # Plain HTTP -> HTTPS. Dokploy does not redirect globally, and without this the site + # answers on http:// with `COOKIE_SECURE=true` — a login that silently never sticks. + - traefik.http.routers.skillnet-http.rule=Host(`${DOMAIN}`) + - traefik.http.routers.skillnet-http.entrypoints=web + - traefik.http.routers.skillnet-http.middlewares=skillnet-https + - traefik.http.middlewares.skillnet-https.redirectscheme.scheme=https + - traefik.http.middlewares.skillnet-https.redirectscheme.permanent=true + depends_on: + skillnet-api: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/health || exit 1"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +networks: + # Created by Dokploy itself when it installs; declared external so this project attaches + # to it instead of trying to own it. If `docker network ls` has no `dokploy-network`, + # this file is being run somewhere Dokploy was never installed — use docker-compose.yml. + dokploy-network: + external: true + +# `extends` copies a service's `volumes:` but not the top-level definitions they point at, +# so these have to be repeated here. Same set, same reasons as in docker-compose.yml: +# database, uploaded documents, and generated media that is expensive to re-create. +volumes: + pgdata: + driver: local + uploads: + driver: local + media_assets: + driver: local + tts_cache: + driver: local From 808cee4bd1aad2ac5c0854f18ed0f54b0b7c0ada Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Ib=C3=A1=C3=B1ez?= Date: Tue, 25 Aug 2026 10:06:30 +0200 Subject: [PATCH 2/5] fix: the Dokploy compose died on a YAML tag Dokploy throws away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dokploy parses the compose file and writes it back out before running it — that is how it injects its own network and labels — and the rewrite drops YAML tags. `depends_on: !override` therefore reached Compose as a plain `depends_on`, which MERGES with the one `extends` had copied from docker-compose.yml, so the deploy failed on `service "skillnet-api" depends on undefined service "db"`. None of this is visible locally: there the file reaches Compose untouched and the tag does exactly what it says. So the file no longer relies on anything that has to survive a round-trip: no `extends`, no tags. `db` and `api` are spelled out, `DATABASE_URL` points at `skillnet-postgres` directly (no alias needed for it any more), and only the `api` alias stays, because docker/nginx.conf is baked into the web image and proxies to `api:8000`. The cost is a second copy of `api`'s ~60-key environment block, which is why the header now says in plain words that it has to be kept in sync — the whole class of bug this repo keeps hitting is one env key not reaching the container. The copy was extracted from docker-compose.yml mechanically and diffed against it: same keys, exactly three deliberate value changes (DATABASE_URL host, COOKIE_SECURE and CORS_ORIGINS, the last two for the same reason the Caddy overlay changes them). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017NQQDUWt5Ga5xTqvjGuSpL --- docker-compose.dokploy.yml | 261 +++++++++++++++++++++++++------------ 1 file changed, 177 insertions(+), 84 deletions(-) diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml index c97c8d87..00e7f932 100644 --- a/docker-compose.dokploy.yml +++ b/docker-compose.dokploy.yml @@ -1,113 +1,206 @@ # SkillNet — Docker Compose for Dokploy (https://dokploy.com) # # In Dokploy: create a project -> "Compose" -> Provider: Git (this repo) -> -# Compose Path: `docker-compose.dokploy.yml` -> put your settings in the +# Compose Path: `./docker-compose.dokploy.yml` -> put your settings in the # Environment tab -> Deploy. # -# ## Why this file exists instead of just pointing Dokploy at docker-compose.yml +# ## What a Dokploy host needs that docker-compose.yml cannot give it # # Dokploy loads exactly ONE compose file, so the `-f base -f overlay` pattern the rest of -# this repo uses (see docker-compose.caddy.yml) is not available. Three things have to -# change for a Dokploy host, and none of them can be expressed from the UI alone: +# this repo uses (see docker-compose.caddy.yml) is not available here. Three things have +# to change, and none can be expressed from the Dokploy UI alone: # -# 1. `web` must NOT publish a host port. Dokploy's Traefik reaches it over the shared -# `dokploy-network`, and a published port would both bypass the host firewall (Docker -# publishes via DNAT) and collide with whatever else on the box already holds 3000. +# 1. `skillnet-web` must NOT publish a host port. Dokploy's Traefik reaches it over the +# shared `dokploy-network`, and a published port would both bypass the host firewall +# (Docker publishes via DNAT) and collide with whatever already holds 3000 on a box +# that runs other projects too. # 2. Every routed service must join the external `dokploy-network`, or Traefik cannot # see it and the domain 404s. -# 3. TLS terminates at Traefik, so `COOKIE_SECURE` has to flip to true — same reason -# docker-compose.caddy.yml flips it. +# 3. TLS terminates at Traefik, so `COOKIE_SECURE` has to flip to true — the same change +# docker-compose.caddy.yml makes. # -# ## Why `extends:` and not a copy of the base file +# ## Why this file repeats `api`'s environment instead of using `extends` # -# `api`'s environment block is ~60 keys, and this repo's history is mostly bugs where one -# of them failed to reach the container. A second hand-maintained copy would drift, and -# the symptom would be "the feature I configured in Dokploy does nothing". `extends` keeps -# docker-compose.yml the single source of truth for what `db` and `api` are; this file only -# states the deltas. +# It did use `extends` at first, which kept docker-compose.yml as the single source of +# truth for that ~60-key block. It does not survive Dokploy: Dokploy parses and rewrites +# the compose file before running it (that is how it injects its own network and labels), +# and the rewrite drops YAML tags. `depends_on: !override` came back as a plain +# `depends_on` that MERGED with the one `extends` had copied from the base file, so the +# deploy died on `service "skillnet-api" depends on undefined service "db"` — a failure +# that never appears locally, because there the file reaches Compose untouched. # -# One sharp edge: `extends` DOES carry `depends_on` over, pointing at the base file's -# service names. Since those names change here, the inherited value is a dangling -# reference (`depends on undefined service "db"`, and compose refuses to start) — and -# re-declaring it would MERGE with the inherited one, not replace it. Hence the -# `!override` tag: https://docs.docker.com/reference/compose-file/merge/#override +# So: plain keys only, no `extends`, no `!override`, no anchors that must survive a +# round-trip. The cost is real and worth naming — THIS BLOCK MUST BE KEPT IN SYNC WITH +# `api.environment` IN docker-compose.yml. Most deployment bugs in this repo's history +# are one env key failing to reach the container, and the symptom of drift here is +# "the setting I filled in on Dokploy does nothing". # -# ## Why the services are renamed, and why they keep network aliases +# ## Service names # -# `db` / `api` / `web` are fine inside a project that owns the whole daemon, but a Dokploy -# host runs every project's containers side by side and the names show up in its UI, in -# `docker ps` and in Traefik's dashboard. So they are `skillnet-postgres`, `skillnet-api` -# and `skillnet-web` here. -# -# The `aliases:` below are NOT decoration — the old names are hardcoded in two places this -# file does not own: -# -# * `docker/nginx.conf` proxies /api/, /ext/ and /health to `http://api:8000`. That file -# is baked into the web image and shared with every other compose overlay in the repo. -# * `DATABASE_URL` is inherited from docker-compose.yml and points at `@db:5432`. -# -# An alias makes both names resolve to the same container, so the rename stays cosmetic -# instead of turning into an edit of files the non-Dokploy deployments also use. +# Prefixed, because a Dokploy host lists every project's containers side by side and +# `api` / `db` / `web` say nothing there. `skillnet-api` keeps an `api` network alias: +# `docker/nginx.conf` is baked into the web image and proxies to `http://api:8000`, and +# that file is shared with every other deployment in the repo — the rename stays cosmetic +# instead of forcing an edit there. `DATABASE_URL` below is written out against the new +# name, so the database needs no alias. # # ## Not included # -# The `a2a`, `mcp` and `api-fixtures` services stay out: they are profile-gated in the base -# file and Dokploy gives no place to pass `--profile`. Add them here explicitly (same -# `extends` shape, minus `profiles`) if you want them on this host. +# `a2a`, `mcp` and `api-fixtures` stay out: they are profile-gated in the base file and +# Dokploy gives no place to pass `--profile`. Add them here explicitly if this host needs +# them. + +# Log rotation, applied to every service below — same bound as docker-compose.yml: enough +# history to debug yesterday, small enough that a months-long deploy cannot fill the disk. +x-logging: &default-logging + driver: json-file + options: + max-size: "10m" + max-file: "3" services: # ── Database ──────────────────────────────────────────────── - # Not on dokploy-network and no published port: nothing outside this compose project - # should be able to reach PostgreSQL. `api` finds it on the project's default network. + # Internal only: no published port and not on dokploy-network, so nothing outside this + # compose project can reach PostgreSQL. skillnet-postgres: - extends: - file: docker-compose.yml - service: db - networks: - default: - # The inherited DATABASE_URL says `db:5432`. Keep that name resolvable. - aliases: - - db + image: pgvector/pgvector:pg16 + restart: unless-stopped + logging: *default-logging + environment: + POSTGRES_USER: ${POSTGRES_USER:-skillnet} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in the Dokploy Environment tab} + POSTGRES_DB: ${POSTGRES_DB:-skillnet} + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-skillnet} -d ${POSTGRES_DB:-skillnet}"] + interval: 5s + timeout: 3s + retries: 10 # ── Backend API ───────────────────────────────────────────── - # Also internal-only. The SPA talks to it through nginx (`web` proxies /api/, /ext/ and - # /health to api:8000, see docker/nginx.conf), so it needs no domain of its own. + # Also internal only. The SPA reaches it through nginx, which proxies /api/, /ext/ and + # /health to it (docker/nginx.conf), so it needs no domain and no published port. skillnet-api: - extends: - file: docker-compose.yml - service: api + build: + context: . + dockerfile: docker/api.Dockerfile + restart: unless-stopped + logging: *default-logging networks: default: - # nginx inside the web image proxies to `api:8000` — see docker/nginx.conf. aliases: + # nginx in the web image proxies to `api:8000`; see the header. - api environment: - # Traefik terminates TLS in front of the whole stack, so session cookies can and - # should be HTTPS-only. The base file defaults this to `false` so a plain-HTTP - # localhost run works; here there is always TLS. + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-skillnet}:${POSTGRES_PASSWORD}@skillnet-postgres:5432/${POSTGRES_DB:-skillnet} + SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env (32 caracteres o mas)} + SESSION_LIFETIME_SECONDS: ${SESSION_LIFETIME_SECONDS:-604800} + # Flipped from the base file's `false`: Traefik terminates TLS in front of the + # whole stack, so session cookies can and should be HTTPS-only. Same change + # docker-compose.caddy.yml makes, and for the same reason. COOKIE_SECURE: ${COOKIE_SECURE:-true} - # The SPA is served from the same origin it calls, so this matters only for external - # API clients — but the base default (`http://localhost:3000`) is wrong for a - # deployed host, and a wrong default here is a browser error with no server log. + # Without this line the `.env` never arrives: no service declares `env_file`, and + # `.dockerignore` keeps `.env` out of the image, so pydantic's `env_file=".env"` + # finds nothing inside the container. Only the keys listed here get through — which + # is why "set DEBUG=true to see Swagger" could never have worked. + DEBUG: ${DEBUG:-false} + # Model defaults live HERE, not as an empty string. `${X:-}` leaves the variable + # *set and empty*, and an empty string beats the default in src/config.py — so with + # `${LLM_MODEL:-}` putting only LLM_API_KEY in `.env` started nothing: the code's + # default never applied inside Docker. With these values an OpenAI key is the only + # thing actually required. + LLM_BASE_URL: ${LLM_BASE_URL:-} + LLM_API_KEY: ${LLM_API_KEY:-} + GEMINI_API_KEY: ${GEMINI_API_KEY:-} + LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini} + LLM_GENERATION_MODEL: ${LLM_GENERATION_MODEL:-} + LLM_TUTOR_MODEL: ${LLM_TUTOR_MODEL:-} + LLM_EVAL_MODEL: ${LLM_EVAL_MODEL:-} + EMBEDDING_BASE_URL: ${EMBEDDING_BASE_URL:-} + EMBEDDING_API_KEY: ${EMBEDDING_API_KEY:-} + EMBEDDING_MODEL: ${EMBEDDING_MODEL:-text-embedding-3-small} + # 768 rather than 384: migration 0008 pins it on `document_chunks.embedding`. A + # mismatch makes every chunk insert fail, so the startup check in src/main.py says + # so in plain words instead of letting it surface when the first document is + # ingested — where it hides inside the ingestion `except`. + EMBEDDING_DIMENSIONS: ${EMBEDDING_DIMENSIONS:-768} + RENDER_BACKEND: ${RENDER_BACKEND:-openui} + LLM_RUNTIME_FAST_MODEL: ${LLM_RUNTIME_FAST_MODEL:-} + LLM_RUNTIME_HEAVY_MODEL: ${LLM_RUNTIME_HEAVY_MODEL:-} + LLM_FIXTURE_DIR: ${LLM_FIXTURE_DIR:-src/llm/fixture_data} + LLM_FIXTURE_MODE: ${LLM_FIXTURE_MODE:-replay} + MULTI_AGENT_RENDER: ${MULTI_AGENT_RENDER:-false} + ADAPTIVE_EPISODES: ${ADAPTIVE_EPISODES:-false} + ONBOARDING_ENABLED: ${ONBOARDING_ENABLED:-true} + WORKSPACE_MODE: ${WORKSPACE_MODE:-organization} + SEMANTIC_ROUTER: ${SEMANTIC_ROUTER:-false} + UPLOAD_DIR: /data/uploads + MAX_UPLOAD_SIZE_MB: ${MAX_UPLOAD_SIZE_MB:-50} + # The SPA is same-origin with the API (nginx proxies /api/), so this matters only + # for external clients — but the base default `http://localhost:3000` is simply + # wrong on a deployed host, and a wrong value here is a browser-side error with + # no server-side log to find it by. CORS_ORIGINS: ${CORS_ORIGINS:-["https://${DOMAIN}"]} - depends_on: !override + LOG_LEVEL: ${LOG_LEVEL:-info} + ENVIRONMENT: ${ENVIRONMENT:-production} + ADMIN_EMAIL: ${ADMIN_EMAIL:-} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} + ORG_NAME: ${ORG_NAME:-SkillNet} + A2A_INTERNAL_API_KEY: ${A2A_INTERNAL_API_KEY:-} + # TTS + Vision follow the same rule as the models above: `.env` is dockerignored, + # no service declares `env_file`, so a key only reaches the container if it is + # listed here. Without these lines `TTS_PROVIDER` defaulted to `disabled` and the + # audio endpoint 503'd even with a valid ElevenLabs key in `.env`. + TTS_PROVIDER: ${TTS_PROVIDER:-disabled} + TTS_API_KEY: ${TTS_API_KEY:-} + TTS_VOICE: ${TTS_VOICE:-} + TTS_LANGUAGE: ${TTS_LANGUAGE:-es} + TTS_AZURE_REGION: ${TTS_AZURE_REGION:-} + TTS_AZURE_ENDPOINT: ${TTS_AZURE_ENDPOINT:-} + TTS_TIMEOUT_SECONDS: ${TTS_TIMEOUT_SECONDS:-30} + VISION_MODEL: ${VISION_MODEL:-} + # Image generation (infographic poster / cover) for openrouter/* models. Same rule as + # the keys above: `.env` is dockerignored, so without this line the key never reaches + # the container and every image call goes out with api_key=None -> 401 Unauthorized. + OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-} + IMAGE_MODEL: ${IMAGE_MODEL:-openrouter/google/gemini-2.5-flash-image} + IMAGE_FALLBACK_MODEL: ${IMAGE_FALLBACK_MODEL:-gpt-image-1} + # Generated media (podcast mp3, infographic/cover png) and the TTS cache. Both + # default to paths RELATIVE to WORKDIR (`/app/data/...`, see src/config.py), which is + # image filesystem, not a volume: every `docker compose up --build` threw them away + # and the demo course came back with broken media. Pointed at `/data` here so the + # named volumes below actually hold them. + MEDIA_ASSETS_DIR: ${MEDIA_ASSETS_DIR:-/data/media_assets} + TTS_CACHE_DIR: ${TTS_CACHE_DIR:-/data/tts_cache} + # Runtime component selection for dynamic courses. Documented in .env.example, so + # they have to reach the container — there is no env_file, and a setting that is + # documented but not wired changes nothing and says nothing when you set it. + RUNTIME_SELECTION_STRATEGY: ${RUNTIME_SELECTION_STRATEGY:-top5/v1} + RUNTIME_SELECTION_EXECUTION: ${RUNTIME_SELECTION_EXECUTION:-live} + volumes: + - uploads:/data/uploads + - media_assets:/data/media_assets + - tts_cache:/data/tts_cache + depends_on: skillnet-postgres: condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import httpx; httpx.get('http://localhost:8000/health').raise_for_status()"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s # ── Frontend (nginx + React SPA) ──────────────────────────── - # Written out in full rather than extended, because the one thing it inherits that must - # not survive is `ports:` — and Compose MERGES sequences like `ports` instead of - # replacing them (the same trap documented at length in docker-compose.caddy.yml). + # The only service Traefik routes to, and the only one with no `ports:` — that absence + # is the point, see the header. skillnet-web: build: context: . dockerfile: docker/web.Dockerfile restart: unless-stopped - logging: - driver: json-file - options: - max-size: "10m" - max-file: "3" + logging: *default-logging networks: # `default` has to be listed explicitly: naming any network opts a service out of # the implicit default, and without it nginx could not resolve the API at all. @@ -115,21 +208,22 @@ services: dokploy-network: {} labels: - traefik.enable=true - # Which network Traefik should use to reach this container. Omitting it makes - # Traefik pick one of the two at random and half the requests time out. + # Which network Traefik should use to reach this container. Without it Traefik picks + # one of the two and half the requests time out. - traefik.docker.network=dokploy-network - # Router names are global to the Dokploy instance's Traefik, not scoped to this - # project — change `skillnet` here if you deploy SkillNet twice on one host. + # Router names are global to the host's Traefik, not scoped to this project — change + # `skillnet` here if you ever deploy SkillNet twice on the same Dokploy instance. - traefik.http.routers.skillnet.rule=Host(`${DOMAIN:?Set DOMAIN in the Dokploy Environment tab to the public hostname}`) - traefik.http.routers.skillnet.entrypoints=websecure - # `letsencrypt` is the resolver Dokploy's own Traefik ships with; certificates are - # issued and renewed by Dokploy, so there is no Caddy/ACME state to keep here. + # `letsencrypt` is the resolver Dokploy's own Traefik ships with: certificates are + # issued and renewed by Dokploy, so unlike the Caddy overlay there is no ACME state + # to keep in a volume here. - traefik.http.routers.skillnet.tls.certresolver=letsencrypt - # The container listens on 80 (nginx). Traefik cannot infer this when a service - # exposes no published port, which is exactly our case. + # nginx listens on 80. Traefik cannot infer the port when a service publishes none, + # which is exactly this case. - traefik.http.services.skillnet.loadbalancer.server.port=80 # Plain HTTP -> HTTPS. Dokploy does not redirect globally, and without this the site - # answers on http:// with `COOKIE_SECURE=true` — a login that silently never sticks. + # answers over http:// with COOKIE_SECURE=true — a login that silently never sticks. - traefik.http.routers.skillnet-http.rule=Host(`${DOMAIN}`) - traefik.http.routers.skillnet-http.entrypoints=web - traefik.http.routers.skillnet-http.middlewares=skillnet-https @@ -146,15 +240,14 @@ services: start_period: 10s networks: - # Created by Dokploy itself when it installs; declared external so this project attaches - # to it instead of trying to own it. If `docker network ls` has no `dokploy-network`, - # this file is being run somewhere Dokploy was never installed — use docker-compose.yml. + # Created by Dokploy when it installs itself; declared external so this project attaches + # to it instead of trying to own it. If `docker network ls` shows no `dokploy-network`, + # this file is running somewhere Dokploy was never installed — use docker-compose.yml. dokploy-network: external: true -# `extends` copies a service's `volumes:` but not the top-level definitions they point at, -# so these have to be repeated here. Same set, same reasons as in docker-compose.yml: -# database, uploaded documents, and generated media that is expensive to re-create. +# Same set and same reasons as docker-compose.yml: the database, uploaded documents, and +# generated media that costs real model calls to rebuild. volumes: pgdata: driver: local From a9ea74dd25748f2e9774d42f1f7c6e4bb48e861b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Ib=C3=A1=C3=B1ez?= Date: Tue, 25 Aug 2026 10:26:33 +0200 Subject: [PATCH 3/5] fix: one owner for the domain, and it is the Dokploy UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compose file carried its own Traefik labels for ${DOMAIN}. They work, but adding the domain from Dokploy's Domains tab makes Dokploy write a second set of labels for the same hostname, and two routers claiming the same `Host()` rule is a coin flip over which one answers. The UI is where a Dokploy operator looks for this, so the UI wins and the labels go. What cannot move to the UI stays: `skillnet-web` still joins the external `dokploy-network`, because the Domains tab adds labels, not networks, and Traefik cannot reach a container it shares no network with. Point that tab at service `skillnet-web`, container port 80 — nginx's port inside the container, not a host port; there is still deliberately none. DOMAIN was previously required by the router label. It is still needed, now by CORS_ORIGINS alone, so the `:?` guard moves there rather than disappearing — otherwise a missing DOMAIN would have silently produced the origin `https://`. Setting CORS_ORIGINS explicitly skips the guard, since Compose only evaluates a default when the variable is unset. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017NQQDUWt5Ga5xTqvjGuSpL --- docker-compose.dokploy.yml | 46 ++++++++++++++------------------------ 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml index 00e7f932..65902d94 100644 --- a/docker-compose.dokploy.yml +++ b/docker-compose.dokploy.yml @@ -2,7 +2,16 @@ # # In Dokploy: create a project -> "Compose" -> Provider: Git (this repo) -> # Compose Path: `./docker-compose.dokploy.yml` -> put your settings in the -# Environment tab -> Deploy. +# Environment tab -> Deploy. Then add the domain from the Domains tab, pointing it at +# service `skillnet-web`, container port `80` (see below). +# +# ## Where the domain is configured, and why not here +# +# In the Domains tab, not in this file. Traefik labels used to live on `skillnet-web`, +# and they work — but Dokploy's UI writes its OWN labels for whatever domain you add +# there, so using both leaves two routers claiming the same `Host()` rule and which one +# answers is a coin flip. One owner only: the UI. That is also why `skillnet-web` +# publishes a `80` the UI can point at, and nothing else. # # ## What a Dokploy host needs that docker-compose.yml cannot give it # @@ -14,8 +23,9 @@ # shared `dokploy-network`, and a published port would both bypass the host firewall # (Docker publishes via DNAT) and collide with whatever already holds 3000 on a box # that runs other projects too. -# 2. Every routed service must join the external `dokploy-network`, or Traefik cannot -# see it and the domain 404s. +# 2. `skillnet-web` must join the external `dokploy-network`, or the Traefik that +# Dokploy runs cannot see it and the domain 404s. This is the half of the routing +# the UI cannot do for you — the Domains tab adds labels, not networks. # 3. TLS terminates at Traefik, so `COOKIE_SECURE` has to flip to true — the same change # docker-compose.caddy.yml makes. # @@ -141,7 +151,7 @@ services: # for external clients — but the base default `http://localhost:3000` is simply # wrong on a deployed host, and a wrong value here is a browser-side error with # no server-side log to find it by. - CORS_ORIGINS: ${CORS_ORIGINS:-["https://${DOMAIN}"]} + CORS_ORIGINS: ${CORS_ORIGINS:-["https://${DOMAIN:?Set DOMAIN in the Dokploy Environment tab to the hostname you added in the Domains tab, or set CORS_ORIGINS explicitly}"]} LOG_LEVEL: ${LOG_LEVEL:-info} ENVIRONMENT: ${ENVIRONMENT:-production} ADMIN_EMAIL: ${ADMIN_EMAIL:-} @@ -193,8 +203,9 @@ services: start_period: 30s # ── Frontend (nginx + React SPA) ──────────────────────────── - # The only service Traefik routes to, and the only one with no `ports:` — that absence - # is the point, see the header. + # The only service Traefik routes to. Point the Domains tab at THIS service name, on + # container port 80 — that is the port nginx listens on inside the container + # (docker/web.Dockerfile), not a host port: there is deliberately no `ports:` here. skillnet-web: build: context: . @@ -206,29 +217,6 @@ services: # the implicit default, and without it nginx could not resolve the API at all. default: {} dokploy-network: {} - labels: - - traefik.enable=true - # Which network Traefik should use to reach this container. Without it Traefik picks - # one of the two and half the requests time out. - - traefik.docker.network=dokploy-network - # Router names are global to the host's Traefik, not scoped to this project — change - # `skillnet` here if you ever deploy SkillNet twice on the same Dokploy instance. - - traefik.http.routers.skillnet.rule=Host(`${DOMAIN:?Set DOMAIN in the Dokploy Environment tab to the public hostname}`) - - traefik.http.routers.skillnet.entrypoints=websecure - # `letsencrypt` is the resolver Dokploy's own Traefik ships with: certificates are - # issued and renewed by Dokploy, so unlike the Caddy overlay there is no ACME state - # to keep in a volume here. - - traefik.http.routers.skillnet.tls.certresolver=letsencrypt - # nginx listens on 80. Traefik cannot infer the port when a service publishes none, - # which is exactly this case. - - traefik.http.services.skillnet.loadbalancer.server.port=80 - # Plain HTTP -> HTTPS. Dokploy does not redirect globally, and without this the site - # answers over http:// with COOKIE_SECURE=true — a login that silently never sticks. - - traefik.http.routers.skillnet-http.rule=Host(`${DOMAIN}`) - - traefik.http.routers.skillnet-http.entrypoints=web - - traefik.http.routers.skillnet-http.middlewares=skillnet-https - - traefik.http.middlewares.skillnet-https.redirectscheme.scheme=https - - traefik.http.middlewares.skillnet-https.redirectscheme.permanent=true depends_on: skillnet-api: condition: service_healthy From 1696c288ec547590d375bb9298617f0ce716a390 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Ib=C3=A1=C3=B1ez?= Date: Tue, 25 Aug 2026 10:35:56 +0200 Subject: [PATCH 4/5] feat: serve the SPA on :3000 without an nginx Dokploy already provides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a Dokploy host Traefik is the reverse proxy, so the nginx inside the web image was a second one to keep in sync for nothing. docker/web.dokploy.Dockerfile builds the same Vite bundle and serves it with `serve` on 3000: history-API fallback and headers come from docker/serve.json, which reproduces the three headers docker/security-headers.conf sets plus the immutable/no-store split between /assets and index.html. Dropping nginx moves one job it was doing, and it is not optional: the SPA calls the API on relative paths (`/api/v1/...`, apps/skillnet-web/src/api/client.ts) and has no configurable base URL, so the Domains tab now needs TWO entries on the same host — `/` to skillnet-web:3000 and `/api` to skillnet-api:8000 (plus `/ext` for the external API). Traefik ranks routers by rule length, so the prefix wins over `/` on its own. That is why skillnet-api joins dokploy-network here; the paths it exposes publicly are the ones nginx already forwarded, so it is a shorter route to the same surface, not a wider one. The web healthcheck drops to `/` because `/health` was nginx proxying to the API. Two things this build does differently, both about failures that look like broken code and are not: pnpm is installed from npm instead of `corepack enable`, whose signature check fails opaquely on hosts with an old keyring, and NODE_OPTIONS raises V8's heap ceiling so `tsc -b && vite build` on a small VPS dies with an error rather than a bare "Killed". The existing web.Dockerfile builds fine here (verified), so the Vite step was never the problem. Verified by running the image: index.html, a React Router path, a hashed asset and the headers all come back as intended, serve.json stays out of the served directory, and the container runs as `node`. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017NQQDUWt5Ga5xTqvjGuSpL --- docker-compose.dokploy.yml | 55 ++++++++++++++++---------- docker/serve.json | 27 +++++++++++++ docker/web.dokploy.Dockerfile | 74 +++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 docker/serve.json create mode 100644 docker/web.dokploy.Dockerfile diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml index 65902d94..9843b52b 100644 --- a/docker-compose.dokploy.yml +++ b/docker-compose.dokploy.yml @@ -2,16 +2,25 @@ # # In Dokploy: create a project -> "Compose" -> Provider: Git (this repo) -> # Compose Path: `./docker-compose.dokploy.yml` -> put your settings in the -# Environment tab -> Deploy. Then add the domain from the Domains tab, pointing it at -# service `skillnet-web`, container port `80` (see below). +# Environment tab -> Deploy. Then add the domain from the Domains tab (see below). # -# ## Where the domain is configured, and why not here +# ## Domains: TWO entries on the same hostname, not one # -# In the Domains tab, not in this file. Traefik labels used to live on `skillnet-web`, -# and they work — but Dokploy's UI writes its OWN labels for whatever domain you add -# there, so using both leaves two routers claiming the same `Host()` rule and which one -# answers is a coin flip. One owner only: the UI. That is also why `skillnet-web` -# publishes a `80` the UI can point at, and nothing else. +# The SPA calls the API on relative paths (`/api/v1/...`) and has no configurable API +# base URL, so something has to route `/api` to the API container. In the nginx image +# that was nginx; here it is Traefik, which means the Domains tab needs both halves: +# +# host / path `/` -> service skillnet-web, container port 3000 +# host / path `/api` -> service skillnet-api, container port 8000 +# host / path `/ext` -> service skillnet-api, container port 8000 (external API only) +# +# Traefik ranks routers by rule length, so `/api` wins over `/` with no manual priority. +# Miss the second entry and the app loads, then every call falls through to index.html: +# a white screen after login rather than an error. +# +# The domain lives in that tab and NOT as Traefik labels in this file. Labels here used +# to work, but Dokploy writes its own for whatever you add in the UI, and two routers +# claiming the same `Host()` rule is a coin flip over which answers. One owner: the UI. # # ## What a Dokploy host needs that docker-compose.yml cannot give it # @@ -89,8 +98,10 @@ services: retries: 10 # ── Backend API ───────────────────────────────────────────── - # Also internal only. The SPA reaches it through nginx, which proxies /api/, /ext/ and - # /health to it (docker/nginx.conf), so it needs no domain and no published port. + # On dokploy-network because Traefik routes `/api` straight to it now that no nginx + # proxies on its behalf. Still no published port: Traefik reaches it over that network, + # and the paths it serves publicly are the same ones nginx used to forward, so this is + # not new surface — just a shorter path to it. skillnet-api: build: context: . @@ -98,10 +109,10 @@ services: restart: unless-stopped logging: *default-logging networks: - default: - aliases: - # nginx in the web image proxies to `api:8000`; see the header. - - api + # `default` must be listed explicitly: naming any network opts the service out of + # the implicit one, and that is how it reaches PostgreSQL. + default: {} + dokploy-network: {} environment: DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-skillnet}:${POSTGRES_PASSWORD}@skillnet-postgres:5432/${POSTGRES_DB:-skillnet} SECRET_KEY: ${SECRET_KEY:?Set SECRET_KEY in .env (32 caracteres o mas)} @@ -203,25 +214,27 @@ services: start_period: 30s # ── Frontend (nginx + React SPA) ──────────────────────────── - # The only service Traefik routes to. Point the Domains tab at THIS service name, on - # container port 80 — that is the port nginx listens on inside the container - # (docker/web.Dockerfile), not a host port: there is deliberately no `ports:` here. + # Static build served by `serve` on 3000 — no nginx, because Traefik already is the + # reverse proxy on a Dokploy host (see docker/web.dokploy.Dockerfile). Point the `/` + # domain entry at THIS service, container port 3000: that is the port inside the + # container, not a host port — there is deliberately no `ports:` here. skillnet-web: build: context: . - dockerfile: docker/web.Dockerfile + dockerfile: docker/web.dokploy.Dockerfile restart: unless-stopped logging: *default-logging networks: - # `default` has to be listed explicitly: naming any network opts a service out of - # the implicit default, and without it nginx could not resolve the API at all. default: {} dokploy-network: {} depends_on: skillnet-api: condition: service_healthy healthcheck: - test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/health || exit 1"] + # `/` and not `/health`: that endpoint was nginx proxying to the API, and this + # image has no proxy. Serving index.html is the whole job here, so it is also the + # honest thing to check. + test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1:3000/ || exit 1"] interval: 10s timeout: 5s retries: 5 diff --git a/docker/serve.json b/docker/serve.json new file mode 100644 index 00000000..9bffef68 --- /dev/null +++ b/docker/serve.json @@ -0,0 +1,27 @@ +{ + "rewrites": [ + { "source": "**", "destination": "/index.html" } + ], + "headers": [ + { + "source": "**", + "headers": [ + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } + ] + }, + { + "source": "assets/**", + "headers": [ + { "key": "Cache-Control", "value": "public, max-age=31536000, immutable" } + ] + }, + { + "source": "index.html", + "headers": [ + { "key": "Cache-Control", "value": "no-store, no-cache, must-revalidate" } + ] + } + ] +} diff --git a/docker/web.dokploy.Dockerfile b/docker/web.dokploy.Dockerfile new file mode 100644 index 00000000..40748d11 --- /dev/null +++ b/docker/web.dokploy.Dockerfile @@ -0,0 +1,74 @@ +# syntax=docker/dockerfile:1 +# SkillNet Web for Dokploy — build the SPA with Vite, serve the static build on :3000. +# Build context is the repo root. +# +# Differs from docker/web.Dockerfile in the runtime stage only: no nginx, because on a +# Dokploy host Traefik is already the reverse proxy and a second one inside the container +# is a layer to keep in sync for nothing. +# +# ## What nginx was also doing, and where it has to go instead +# +# The SPA calls the API on RELATIVE paths (`/api/v1/...`, see apps/skillnet-web/src/api/ +# client.ts) — there is no configurable API base URL. nginx used to proxy `/api/` and +# `/ext/` to the API container, so dropping it moves that job to Traefik. In Dokploy's +# Domains tab you need TWO entries on the same hostname: +# +# host / path `/` -> service skillnet-web, container port 3000 +# host / path `/api` -> service skillnet-api, container port 8000 +# +# (plus `/ext` -> skillnet-api:8000 if you use the external API). Traefik ranks routers by +# rule length, so the `/api` prefix wins over `/` without any manual priority. Without that +# second entry the app loads and then every request 404s into index.html — a white screen +# after login, not an error page. + +# ── Stage 1: Build the SPA ─────────────────────────────────────────── +FROM node:22-alpine AS builder + +# pnpm from npm rather than `corepack enable`. Corepack downloads the version pinned in +# package.json#packageManager at first use and verifies its signature, which needs network +# and an up-to-date keyring at BUILD time — the failure mode is an opaque +# "Cannot find matching keyid" that has nothing to do with this project's code. Pinning +# the same version here keeps the lockfile honest without that moving part. +RUN npm install --global pnpm@11.9.0 + +WORKDIR /build + +# Lockfile + manifests first, so a source-only change reuses the dependency layer. +COPY apps/skillnet-web/package.json apps/skillnet-web/pnpm-lock.yaml apps/skillnet-web/pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +# Source, then production build. +COPY apps/skillnet-web/index.html apps/skillnet-web/vite.config.ts ./ +COPY apps/skillnet-web/tsconfig.json apps/skillnet-web/tsconfig.app.json apps/skillnet-web/tsconfig.node.json ./ +COPY apps/skillnet-web/src ./src +COPY apps/skillnet-web/vendor ./vendor +COPY apps/skillnet-web/public ./public + +# `pnpm run build` is `tsc -b && vite build`. Both are memory-hungry on a repo this size, +# and a small VPS kills them with a bare "Killed" / exit 137 that reads like a broken +# build rather than an out-of-memory. 4 GB is the ceiling, not a reservation: V8 grows +# into it only if the build needs it. +ENV NODE_OPTIONS=--max-old-space-size=4096 +RUN pnpm run build + +# ── Stage 2: Serve the static build ────────────────────────────────── +FROM node:22-alpine AS runtime + +# `serve` handles the two things a SPA needs from a static server: the history-API +# fallback (any unknown path returns index.html so React Router can take over) and +# per-path headers. Both are configured in serve.json, not on the command line. +RUN npm install --global serve@14 + +WORKDIR /app + +COPY --from=builder /build/dist ./dist +# Kept OUTSIDE ./dist on purpose: anything inside the served directory is a public URL, +# and the config is nobody's business but the server's. +COPY docker/serve.json ./serve.json + +# The image ships a `node` user; running the static server as root buys nothing. +USER node + +EXPOSE 3000 + +CMD ["serve", "--listen", "3000", "--config", "/app/serve.json", "/app/dist"] From d3a9a4c784bca3ba5e29cea536d6755be7350df5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Ib=C3=A1=C3=B1ez?= Date: Tue, 25 Aug 2026 10:44:04 +0200 Subject: [PATCH 5/5] docs: name the two ways the /api route goes wrong on Dokploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header said the Domains tab needs an `/api` entry. It did not say what missing it looks like — `GET /api/v1/setup/status` returning 200 with `filename="index.html"`, which reads like a frontend bug — nor that "Strip Path" has to stay off, since `/api/v1` is where FastAPI mounts its routes rather than a prefix to peel away. Both produce a login that never fires. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_017NQQDUWt5Ga5xTqvjGuSpL --- docker-compose.dokploy.yml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml index 9843b52b..72256a89 100644 --- a/docker-compose.dokploy.yml +++ b/docker-compose.dokploy.yml @@ -15,8 +15,13 @@ # host / path `/ext` -> service skillnet-api, container port 8000 (external API only) # # Traefik ranks routers by rule length, so `/api` wins over `/` with no manual priority. -# Miss the second entry and the app loads, then every call falls through to index.html: -# a white screen after login rather than an error. +# Miss the second entry and the app loads, then every call falls through to index.html — +# `GET /api/v1/setup/status` comes back 200 with `filename="index.html"` and the SPA is +# parsing HTML as JSON. It reads like a frontend bug and is a missing route. +# +# Leave "Strip Path" OFF on that entry. `/api/v1` is where FastAPI actually mounts its +# routes (src/main.py), not a routing prefix to peel off: strip it and the API gets +# `/v1/setup/status` and answers 404 — same broken login, different cause. # # The domain lives in that tab and NOT as Traefik labels in this file. Labels here used # to work, but Dokploy writes its own for whatever you add in the UI, and two routers