From 46ac0da7d643aa7fd4cf71c12524ad01d165ff2d Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 22 Sep 2026 12:42:46 +0200 Subject: [PATCH 1/3] refactor: route traefik straight to node and drop nginx nginx was a pure proxy - no static files, no cache, no auth - and it resolved node's IP once at startup, so a node container returning on a new IP meant a permanent 502 while nginx itself stayed healthy (F1). The traefik labels move to node, including the basic auth middleware on staging and the www redirect, so the routers keep their middlewares. The node image has no EXPOSE, so the port is stated explicitly. Claude-Session: https://claude.ai/code/session_012qXhBodStu75USEqkDEHKH --- .docker/vhost.conf | 15 --------------- docker-compose.dev.yml | 2 +- docker-compose.redirect.yml | 2 +- docker-compose.server.yml | 31 ++++++++++--------------------- docker-compose.yml | 28 +++++++++------------------- 5 files changed, 21 insertions(+), 57 deletions(-) delete mode 100644 .docker/vhost.conf diff --git a/.docker/vhost.conf b/.docker/vhost.conf deleted file mode 100644 index 860d5f3..0000000 --- a/.docker/vhost.conf +++ /dev/null @@ -1,15 +0,0 @@ -server { - listen 8080; - server_name localhost; - root /app/public; - - location / { - proxy_set_header X-Forwarded-For $remote_addr; - proxy_set_header Host $http_host; - proxy_pass http://node:3000; - } - - # Send log message to files symlinked to stdout/stderr. - error_log /dev/stderr; - access_log /dev/stdout; -} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index fa0a1a4..7760b4b 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -2,6 +2,6 @@ version: "3" services: - nginx: + node: labels: - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.middlewares=ITKBasicAuth@file" diff --git a/docker-compose.redirect.yml b/docker-compose.redirect.yml index ee6568c..7f807e0 100644 --- a/docker-compose.redirect.yml +++ b/docker-compose.redirect.yml @@ -2,7 +2,7 @@ version: "3" services: - nginx: + node: labels: # Add www before domain and set redirect to non-www - "traefik.http.routers.www_${COMPOSE_PROJECT_NAME}-http.rule=Host(`www.${COMPOSE_SERVER_DOMAIN}`)" diff --git a/docker-compose.server.yml b/docker-compose.server.yml index b068384..fd51214 100644 --- a/docker-compose.server.yml +++ b/docker-compose.server.yml @@ -9,19 +9,20 @@ networks: internal: false services: - nginx: - image: nginxinc/nginx-unprivileged:alpine + node: + image: node:24 restart: unless-stopped + command: yarn start + working_dir: /app + environment: + - NODE_ENV=production networks: - app - frontend - depends_on: - - node ports: - - "8080" + - "3000" volumes: - - ${PWD}/.docker/vhost.conf:/etc/nginx/conf.d/default.conf:ro - - ./:/app:rw + - .:/app:delegated labels: - "traefik.enable=true" - "traefik.docker.network=frontend" @@ -31,17 +32,5 @@ services: - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${COMPOSE_SERVER_DOMAIN}`)" - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.entrypoints=websecure" - - node: - image: node:24 - restart: unless-stopped - command: yarn start - working_dir: /app - environment: - - NODE_ENV=production - networks: - - app - ports: - - "3000" - volumes: - - .:/app:delegated + # The node image has no EXPOSE, so traefik cannot guess the port. + - "traefik.http.services.${COMPOSE_PROJECT_NAME}.loadbalancer.server.port=3000" diff --git a/docker-compose.yml b/docker-compose.yml index 02972f6..6da27da 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,25 +6,6 @@ networks: internal: false services: - nginx: - image: nginxinc/nginx-unprivileged:alpine - networks: - - app - - frontend - depends_on: - - node - ports: - - "8080" - volumes: - - ${PWD}/.docker/vhost.conf:/etc/nginx/conf.d/default.conf:ro - - ./:/app:delegated - labels: - - "traefik.enable=true" - - "traefik.docker.network=frontend" - - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${COMPOSE_DOMAIN}`)" - # - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.middlewares=redirect-to-https" - # - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" - mssql: # Opt-in: docker compose --profile db up -d # Default image is arm64-native so it runs unemulated on Apple Silicon. @@ -50,12 +31,21 @@ services: working_dir: /app networks: - app + - frontend ports: - "127.0.0.1::3000" environment: - NODE_ENV=development volumes: - .:/app:delegated + labels: + - "traefik.enable=true" + - "traefik.docker.network=frontend" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.rule=Host(`${COMPOSE_DOMAIN}`)" + # The node image has no EXPOSE, so traefik cannot guess the port. + - "traefik.http.services.${COMPOSE_PROJECT_NAME}.loadbalancer.server.port=3000" + # - "traefik.http.routers.${COMPOSE_PROJECT_NAME}.middlewares=redirect-to-https" + # - "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https" volumes: mssql-data: From 6c2676f866f82fad922c263c6f911b667d0b2003 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 22 Sep 2026 12:43:01 +0200 Subject: [PATCH 2/3] fix: trust the forwarded headers traefik sets The hand-written vhost overwrote X-Forwarded-For with traefik's own IP and never set X-Forwarded-Proto, which is why the index page emitted http:// links over TLS (F12). With nginx gone traefik sets both correctly; express only needs to be told to believe the one hop. Claude-Session: https://claude.ai/code/session_012qXhBodStu75USEqkDEHKH --- app.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app.js b/app.js index 5ab3a70..9929b17 100644 --- a/app.js +++ b/app.js @@ -6,6 +6,11 @@ const sql = require('mssql') const app = express() +// Traefik is the only thing in front of the app and it sets X-Forwarded-For +// and X-Forwarded-Proto itself. Trust that one hop, so req.ip is the real +// client and req.protocol is https on the index page's links. +app.set('trust proxy', 1) + const config = require('./config') // One line per request: method, path, status, duration, row count, client ip. From 18fbfd42667808526fff28851bfc8726f82e74f4 Mon Sep 17 00:00:00 2001 From: turegjorup Date: Tue, 22 Sep 2026 12:44:36 +0200 Subject: [PATCH 3/3] test: reach the app on node:3000 instead of through nginx Also asserts that the index honours X-Forwarded-Proto, which is the observable half of the trust proxy change - it fails on http:// links without it. Claude-Session: https://claude.ai/code/session_012qXhBodStu75USEqkDEHKH --- README.md | 2 +- test.js | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9034d5f..2552ffb 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ If that major version changes, update the pin in ## Test the data ```sh -open "http://$(docker compose port nginx 8080)" +open "http://$(docker compose port node 3000)" ``` ## Coding standards diff --git a/test.js b/test.js index ca2374a..eea9179 100644 --- a/test.js +++ b/test.js @@ -11,7 +11,7 @@ const assert = require('assert') const fs = require('fs') const path = require('path') -const BASE = process.env.BASE_URL || 'http://nginx:8080' +const BASE = process.env.BASE_URL || 'http://node:3000' const SEEDED_ROWS = 500 const checks = [] @@ -25,6 +25,14 @@ check('index lists both routes', async () => { assert.ok(body.posidryeartsl_old, 'posidryeartsl_old missing from index') }) +// Traefik terminates TLS and forwards X-Forwarded-Proto. Without +// app.set('trust proxy') express ignores it and the index emits http:// links +// on a page served over https. +check('the index honours the forwarded protocol', async () => { + const body = await (await fetch(`${BASE}/`, { headers: { 'x-forwarded-proto': 'https' } })).json() + assert.ok(body.posidryeartsl.csv.startsWith('https://'), `forwarded proto ignored: ${body.posidryeartsl.csv}`) +}) + check('csv has the expected columns', async () => { const res = await fetch(`${BASE}/posidryeartsl.csv`) assert.strictEqual(res.status, 200)