Everything needed to build and run this on Dokploy at linkdr.serfaty.site, plus the two things that turned out to be broken the moment it left a laptop. The build did not work in a container at all. `lib/auth.ts` throws when AUTH_SECRET or NEXT_PUBLIC_APP_URL is missing — correct at boot, wrong during `next build`, which imports every route module with NODE_ENV=production and none of the runtime secrets. The only way past it was baking a session key into an image layer, which is worse than the problem the guard exists to prevent. Both checks now skip NEXT_PHASE=phase-production-build and still fire on a real boot. Corepack in node:22.12-alpine ships expired npm registry signing keys and dies before it can download pnpm, so the image installs corepack first and prepares the pinned version explicitly. The image is the standalone trace, which needs outputFileTracingRoot at the REPO root: pnpm hoists to a root .pnpm store and tracing from apps/web silently omits every workspace package. 427MB, runs as non-root, and its healthcheck talks to Postgres — a container that cannot reach its database must never enter rotation, because a deploy that goes green and then 500s does not roll back. DEMO_LOGIN is a login bypass under NODE_ENV=production and there is no honest way to describe it otherwise. It is a separate variable from ALLOW_DEV_LOGIN so that copying a dev .env into a real environment cannot enable it by accident, it still only affects the one seeded number, and it prints a boot warning every single start so it cannot be forgotten. That deployment holds nothing but fixtures. It comes out before the platform sees a real signup. Also: /api/health, and next/image hosts corrected to the Spaces bucket rather than the R2 one this stopped using. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
181 lines
7.4 KiB
YAML
181 lines
7.4 KiB
YAML
# Linkdr — Dokploy deployment stack.
|
|
#
|
|
# Separate from docker-compose.yml, which exists only to give a developer a
|
|
# Postgres and a Redis on their laptop. Merging the two would mean one file
|
|
# that is wrong in both places.
|
|
#
|
|
# In Dokploy: create a **Compose** service, point it at this file, paste the
|
|
# variables from DEPLOY.md into the Environment tab, then Deploy.
|
|
#
|
|
# Traefik is Dokploy's ingress. It routes by the labels on `app` below and
|
|
# joins containers on the shared `dokploy-network`, which is why that network
|
|
# is declared external — Dokploy created it, this stack only attaches to it.
|
|
|
|
services:
|
|
# ─────────────────────────────── database ───────────────────────────────
|
|
# PostGIS, not plain Postgres. Every distance in this product is
|
|
# `ST_Distance` over a `geography(Point,4326)` column, and the first
|
|
# migration declares one — vanilla postgres:17 fails on migration 0001.
|
|
postgres:
|
|
image: postgis/postgis:17-3.5
|
|
restart: unless-stopped
|
|
environment:
|
|
POSTGRES_USER: ${POSTGRES_USER:-linkdr}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
|
POSTGRES_DB: ${POSTGRES_DB:-linkdr}
|
|
volumes:
|
|
- pgdata:/var/lib/postgresql/data
|
|
# No `ports:` on purpose. The database is reachable on the compose network
|
|
# by every service that needs it; publishing 5432 puts it on the public
|
|
# internet of the droplet.
|
|
healthcheck:
|
|
test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-linkdr} -d ${POSTGRES_DB:-linkdr}']
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 10
|
|
start_period: 30s
|
|
networks: [internal]
|
|
|
|
# ──────────────────────────────── redis ─────────────────────────────────
|
|
# Not on the request path yet (routers/message.ts throttles in-process until
|
|
# M4). Here so the SSE fan-out and BullMQ queues have somewhere to land
|
|
# without a second deploy.
|
|
redis:
|
|
image: redis:7-alpine
|
|
restart: unless-stopped
|
|
command: redis-server --appendonly yes
|
|
volumes:
|
|
- redisdata:/data
|
|
healthcheck:
|
|
test: ['CMD', 'redis-cli', 'ping']
|
|
interval: 10s
|
|
timeout: 3s
|
|
retries: 10
|
|
networks: [internal]
|
|
|
|
# ────────────────────────────── migrations ──────────────────────────────
|
|
# Runs to completion and exits. `app` waits for it, so a container can never
|
|
# serve traffic against a schema older than the code inside it.
|
|
#
|
|
# Idempotent — drizzle records what it has applied, so a redeploy re-runs
|
|
# this and it does nothing.
|
|
migrate:
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile
|
|
target: tools
|
|
restart: 'no'
|
|
environment:
|
|
DATABASE_URL: ${DATABASE_URL}
|
|
DATABASE_CA_CERT: ${DATABASE_CA_CERT:-}
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
networks: [internal]
|
|
|
|
# ──────────────────────────────── the app ───────────────────────────────
|
|
app:
|
|
build:
|
|
context: .
|
|
dockerfile: Dockerfile
|
|
target: runtime
|
|
# NEXT_PUBLIC_* is inlined into the browser bundle when `next build`
|
|
# runs, so these MUST be build args. Setting them only under
|
|
# `environment:` below leaves the client bundle holding whatever was
|
|
# baked in — usually localhost — and sign-in breaks in a way that looks
|
|
# like a cookie bug.
|
|
args:
|
|
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
|
|
NEXT_PUBLIC_CITY_NAME: ${NEXT_PUBLIC_CITY_NAME}
|
|
NEXT_PUBLIC_CITY_LAT: ${NEXT_PUBLIC_CITY_LAT}
|
|
NEXT_PUBLIC_CITY_LNG: ${NEXT_PUBLIC_CITY_LNG}
|
|
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
|
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY:-}
|
|
restart: unless-stopped
|
|
depends_on:
|
|
postgres:
|
|
condition: service_healthy
|
|
redis:
|
|
condition: service_healthy
|
|
migrate:
|
|
condition: service_completed_successfully
|
|
environment:
|
|
NODE_ENV: production
|
|
PORT: '3000'
|
|
HOSTNAME: 0.0.0.0
|
|
|
|
DATABASE_URL: ${DATABASE_URL}
|
|
DATABASE_CA_CERT: ${DATABASE_CA_CERT:-}
|
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379}
|
|
|
|
# better-auth derives cookie domain and Secure flag from this. An http://
|
|
# value here on an https:// site produces a login that appears to succeed
|
|
# and then has no session — see lib/auth.ts.
|
|
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET is required}
|
|
AUTH_URL: ${AUTH_URL}
|
|
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
|
|
|
|
AUTH_GOOGLE_ID: ${AUTH_GOOGLE_ID:-}
|
|
AUTH_GOOGLE_SECRET: ${AUTH_GOOGLE_SECRET:-}
|
|
AUTH_MICROSOFT_ID: ${AUTH_MICROSOFT_ID:-}
|
|
AUTH_MICROSOFT_SECRET: ${AUTH_MICROSOFT_SECRET:-}
|
|
AUTH_MICROSOFT_TENANT_ID: ${AUTH_MICROSOFT_TENANT_ID:-common}
|
|
AUTH_GITHUB_ID: ${AUTH_GITHUB_ID:-}
|
|
AUTH_GITHUB_SECRET: ${AUTH_GITHUB_SECRET:-}
|
|
|
|
TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID:-}
|
|
TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN:-}
|
|
TWILIO_VERIFY_SERVICE_SID: ${TWILIO_VERIFY_SERVICE_SID:-}
|
|
TWILIO_FROM_NUMBER: ${TWILIO_FROM_NUMBER:-}
|
|
|
|
MAPBOX_TOKEN: ${MAPBOX_TOKEN:-}
|
|
MAPBOX_COUNTRY: ${MAPBOX_COUNTRY:-mx}
|
|
|
|
SPACES_REGION: ${SPACES_REGION:-nyc3}
|
|
SPACES_BUCKET: ${SPACES_BUCKET:-}
|
|
SPACES_KEY: ${SPACES_KEY:-}
|
|
SPACES_SECRET: ${SPACES_SECRET:-}
|
|
SPACES_CDN_URL: ${SPACES_CDN_URL:-}
|
|
|
|
RESEND_API_KEY: ${RESEND_API_KEY:-}
|
|
EMAIL_FROM: ${EMAIL_FROM:-noreply@linkdr.serfaty.site}
|
|
|
|
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
|
|
STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
|
|
PLATFORM_FEE_BPS: ${PLATFORM_FEE_BPS:-1500}
|
|
|
|
NEXT_PUBLIC_SENTRY_DSN: ${NEXT_PUBLIC_SENTRY_DSN:-}
|
|
|
|
# Read by dev-login.ts, which ALSO requires NODE_ENV !== 'production'.
|
|
# With NODE_ENV=production above, the fixed +52 55 0000 0000 / 000000
|
|
# login is off no matter what this says. See DEPLOY.md → "Signing in".
|
|
ALLOW_DEV_LOGIN: 'false'
|
|
networks: [internal, dokploy-network]
|
|
labels:
|
|
- traefik.enable=true
|
|
- traefik.docker.network=dokploy-network
|
|
# Dokploy's Traefik terminates TLS; the container speaks plain HTTP.
|
|
- traefik.http.services.linkdr.loadbalancer.server.port=3000
|
|
- traefik.http.routers.linkdr.rule=Host(`linkdr.serfaty.site`)
|
|
- traefik.http.routers.linkdr.entrypoints=websecure
|
|
- traefik.http.routers.linkdr.tls=true
|
|
- traefik.http.routers.linkdr.tls.certresolver=letsencrypt
|
|
# Send :80 to :443 rather than serving the app on both. Auth cookies are
|
|
# Secure, so the http origin cannot hold a session anyway.
|
|
- traefik.http.routers.linkdr-web.rule=Host(`linkdr.serfaty.site`)
|
|
- traefik.http.routers.linkdr-web.entrypoints=web
|
|
- traefik.http.routers.linkdr-web.middlewares=linkdr-https
|
|
- traefik.http.middlewares.linkdr-https.redirectscheme.scheme=https
|
|
- traefik.http.middlewares.linkdr-https.redirectscheme.permanent=true
|
|
|
|
volumes:
|
|
pgdata:
|
|
redisdata:
|
|
|
|
networks:
|
|
# Private to this stack. Postgres and Redis are reachable here and nowhere else.
|
|
internal:
|
|
# Created by Dokploy for Traefik. Only `app` joins it.
|
|
dokploy-network:
|
|
external: true
|