diff --git a/docker-compose.portainer.yml b/docker-compose.portainer.yml index 00dd6d3..462edc3 100644 --- a/docker-compose.portainer.yml +++ b/docker-compose.portainer.yml @@ -34,6 +34,11 @@ services: volumes: - /mnt/config/docker/kutt/redis:/data command: redis-server --appendonly yes + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 5 kutt: image: kutt/kutt:latest @@ -46,7 +51,17 @@ services: kutt_db: condition: service_healthy kutt_redis: - condition: service_started + condition: service_healthy + healthcheck: + test: + [ + 'CMD-SHELL', + "node -e \"require('http').get('http://127.0.0.1:3000/', (r) => { r.resume(); process.exit(r.statusCode >= 200 && r.statusCode < 500 ? 0 : 1); }).on('error', () => process.exit(1))\"", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s environment: ADMIN_EMAILS: ${ADMIN_EMAILS:?Set ADMIN_EMAILS} DEFAULT_DOMAIN: mifi.me @@ -85,7 +100,8 @@ services: - 'traefik.http.routers.kutt-mifi.tls.certresolver=letsencrypt' - 'traefik.http.routers.kutt-mifi.service=kutt-short' - 'traefik.http.services.kutt-short.loadbalancer.server.port=3000' - - 'traefik.http.services.kutt-short.loadbalancer.serversTransport=kutt-long-timeout' + # Backend timeout: use transport from file provider (see traefik-kutt-timeout.example.yml). + - 'traefik.http.services.kutt-short.loadbalancer.serversTransport=kutt-long-timeout@file' qr_api: image: ${REGISTRY:-git.mifi.dev}/mifi-holdings/shorty-qr-api:${IMAGE_TAG:-latest} @@ -103,6 +119,18 @@ services: KUTT_API_KEY: ${KUTT_API_KEY:-} KUTT_BASE_URL: http://kutt:3000 SHORT_DOMAIN: https://mifi.me + healthcheck: + test: + [ + 'CMD', + 'node', + '-e', + "require('http').get('http://127.0.0.1:8080/health', (r) => { r.resume(); process.exit(r.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s qr_web: image: ${REGISTRY:-git.mifi.dev}/mifi-holdings/shorty-qr-web:${IMAGE_TAG:-latest} @@ -112,9 +140,20 @@ services: - marina-net - backend depends_on: - - qr_api + qr_api: + condition: service_healthy environment: QR_API_URL: http://qr_api:8080 + healthcheck: + test: + [ + 'CMD-SHELL', + "node -e \"require('http').get('http://127.0.0.1:3000/', (r) => { r.resume(); process.exit(r.statusCode >= 200 && r.statusCode < 500 ? 0 : 1); }).on('error', () => process.exit(1))\"", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s labels: - 'traefik.enable=true' - 'docker.network=marina-net' diff --git a/docker-compose.yml b/docker-compose.yml index 4db4579..b852297 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,11 @@ services: volumes: - /mnt/config/docker/kutt/redis:/data command: redis-server --appendonly yes + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 10s + timeout: 5s + retries: 5 kutt: image: kutt/kutt:latest @@ -39,7 +44,17 @@ services: kutt_db: condition: service_healthy kutt_redis: - condition: service_started + condition: service_healthy + healthcheck: + test: + [ + 'CMD-SHELL', + "node -e \"require('http').get('http://127.0.0.1:3000/', (r) => { r.resume(); process.exit(r.statusCode >= 200 && r.statusCode < 500 ? 0 : 1); }).on('error', () => process.exit(1))\"", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s environment: DB_CLIENT: pg DB_HOST: kutt_db @@ -85,6 +100,18 @@ services: KUTT_API_KEY: ${KUTT_API_KEY:-} KUTT_BASE_URL: http://kutt:3000 SHORT_DOMAIN: https://mifi.me + healthcheck: + test: + [ + 'CMD', + 'node', + '-e', + "require('http').get('http://127.0.0.1:8080/health', (r) => { r.resume(); process.exit(r.statusCode === 200 ? 0 : 1); }).on('error', () => process.exit(1))", + ] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s qr_web: build: @@ -95,9 +122,20 @@ services: - marina-net - backend depends_on: - - qr_api + qr_api: + condition: service_healthy environment: QR_API_URL: http://qr_api:8080 + healthcheck: + test: + [ + 'CMD-SHELL', + "node -e \"require('http').get('http://127.0.0.1:3000/', (r) => { r.resume(); process.exit(r.statusCode >= 200 && r.statusCode < 500 ? 0 : 1); }).on('error', () => process.exit(1))\"", + ] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s labels: - 'traefik.enable=true' - 'docker.network=marina-net' diff --git a/qr-api/Dockerfile b/qr-api/Dockerfile index a649c0b..525470a 100644 --- a/qr-api/Dockerfile +++ b/qr-api/Dockerfile @@ -1,4 +1,5 @@ -FROM node:20-alpine AS builder +# Use Debian-based image so better-sqlite3 prebuilds (glibc) work; Alpine/musl has no prebuilds. +FROM node:20-bookworm-slim AS builder RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /app COPY package.json pnpm-lock.yaml* ./ @@ -6,7 +7,7 @@ RUN pnpm install COPY . . RUN pnpm run build -FROM node:20-alpine +FROM node:20-bookworm-slim RUN corepack enable && corepack prepare pnpm@latest --activate WORKDIR /app ENV NODE_ENV=production diff --git a/qr-web/src/app/api/folders/[id]/route.ts b/qr-web/src/app/api/folders/[id]/route.ts index 40b81d5..62dcf6e 100644 --- a/qr-web/src/app/api/folders/[id]/route.ts +++ b/qr-web/src/app/api/folders/[id]/route.ts @@ -18,7 +18,13 @@ export async function GET( } return Response.json(data); } catch (e) { - return Response.json({ error: String(e) }, { status: 502 }); + return Response.json( + { + error: 'QR API unreachable', + detail: e instanceof Error ? e.message : String(e), + }, + { status: 502 }, + ); } } @@ -43,7 +49,13 @@ export async function PUT( } return Response.json(data); } catch (e) { - return Response.json({ error: String(e) }, { status: 502 }); + return Response.json( + { + error: 'QR API unreachable', + detail: e instanceof Error ? e.message : String(e), + }, + { status: 502 }, + ); } } @@ -65,6 +77,12 @@ export async function DELETE( { status: res.status }, ); } catch (e) { - return Response.json({ error: String(e) }, { status: 502 }); + return Response.json( + { + error: 'QR API unreachable', + detail: e instanceof Error ? e.message : String(e), + }, + { status: 502 }, + ); } } diff --git a/qr-web/src/app/api/folders/route.ts b/qr-web/src/app/api/folders/route.ts index d27ea64..655fc51 100644 --- a/qr-web/src/app/api/folders/route.ts +++ b/qr-web/src/app/api/folders/route.ts @@ -12,7 +12,13 @@ export async function GET() { } return Response.json(Array.isArray(data) ? data : data); } catch (e) { - return Response.json({ error: String(e) }, { status: 502 }); + return Response.json( + { + error: 'QR API unreachable', + detail: e instanceof Error ? e.message : String(e), + }, + { status: 502 }, + ); } } @@ -33,6 +39,12 @@ export async function POST(request: Request) { } return Response.json(data); } catch (e) { - return Response.json({ error: String(e) }, { status: 502 }); + return Response.json( + { + error: 'QR API unreachable', + detail: e instanceof Error ? e.message : String(e), + }, + { status: 502 }, + ); } } diff --git a/qr-web/src/app/api/projects/[id]/route.ts b/qr-web/src/app/api/projects/[id]/route.ts index e171ab9..233d6af 100644 --- a/qr-web/src/app/api/projects/[id]/route.ts +++ b/qr-web/src/app/api/projects/[id]/route.ts @@ -24,7 +24,13 @@ export async function GET( } return Response.json(data); } catch (e) { - return Response.json({ error: String(e) }, { status: 502 }); + return Response.json( + { + error: 'QR API unreachable', + detail: e instanceof Error ? e.message : String(e), + }, + { status: 502 }, + ); } } @@ -55,7 +61,13 @@ export async function PUT( } return Response.json(data); } catch (e) { - return Response.json({ error: String(e) }, { status: 502 }); + return Response.json( + { + error: 'QR API unreachable', + detail: e instanceof Error ? e.message : String(e), + }, + { status: 502 }, + ); } } @@ -77,6 +89,12 @@ export async function DELETE( { status: res.status }, ); } catch (e) { - return Response.json({ error: String(e) }, { status: 502 }); + return Response.json( + { + error: 'QR API unreachable', + detail: e instanceof Error ? e.message : String(e), + }, + { status: 502 }, + ); } } diff --git a/qr-web/src/app/api/projects/route.ts b/qr-web/src/app/api/projects/route.ts index 4a09071..daa4bb0 100644 --- a/qr-web/src/app/api/projects/route.ts +++ b/qr-web/src/app/api/projects/route.ts @@ -21,7 +21,13 @@ export async function GET() { } return Response.json(Array.isArray(data) ? rewriteLogoUrl(data) : data); } catch (e) { - return Response.json({ error: String(e) }, { status: 502 }); + return Response.json( + { + error: 'QR API unreachable', + detail: e instanceof Error ? e.message : String(e), + }, + { status: 502 }, + ); } } @@ -42,6 +48,12 @@ export async function POST(request: Request) { } return Response.json(data); } catch (e) { - return Response.json({ error: String(e) }, { status: 502 }); + return Response.json( + { + error: 'QR API unreachable', + detail: e instanceof Error ? e.message : String(e), + }, + { status: 502 }, + ); } } diff --git a/qr-web/src/app/api/shorten/route.ts b/qr-web/src/app/api/shorten/route.ts index 19fb026..db6f5ee 100644 --- a/qr-web/src/app/api/shorten/route.ts +++ b/qr-web/src/app/api/shorten/route.ts @@ -17,6 +17,12 @@ export async function POST(request: Request) { } return Response.json(data); } catch (e) { - return Response.json({ error: String(e) }, { status: 502 }); + return Response.json( + { + error: 'QR API unreachable', + detail: e instanceof Error ? e.message : String(e), + }, + { status: 502 }, + ); } } diff --git a/traefik-kutt-timeout.example.yml b/traefik-kutt-timeout.example.yml index 6f7c8b7..74cf664 100644 --- a/traefik-kutt-timeout.example.yml +++ b/traefik-kutt-timeout.example.yml @@ -5,7 +5,8 @@ # Place this file in the same directory as your other dynamic config (e.g. /etc/traefik/conf.d/) # so the file provider picks it up. # -# Then uncomment the serversTransport label on the kutt service in docker-compose.portainer.yml. +# In docker-compose.portainer.yml set the kutt service label to use this transport with @file: +# traefik.http.services.kutt-short.loadbalancer.serversTransport=kutt-long-timeout@file # # responseHeaderTimeout: time to wait for backend response headers (0 = no timeout). # 300s helps avoid 504 Gateway Timeout when Kutt or OIDC is slow.