# 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