diff --git a/Dockerfile b/Dockerfile index acf45ce2f..abb8d9392 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,6 +46,9 @@ COPY patches/ patches/ RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile COPY . . +# Set CI=true to skip the Sentry sourcemap upload (no auth token needed) +ARG CI +ENV CI=$CI RUN pnpm run build-prod # ---- Development stage ---- diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 000000000..42cd2932f --- /dev/null +++ b/compose.yaml @@ -0,0 +1,172 @@ +# Coolify / Cloudflare Tunnel deployment for PubPub. +# Same pattern as nursing-night-shift / nursing-lab-gentle-truths (see their DEPLOY.md): +# +# Browser -> Cloudflare -> cloudflared (on the Docker host) -> 127.0.0.1:PORT -> app (listens on PORT) +# +# 1. Coolify: New Resource -> Docker Compose (this repo). Leave the Domains field +# EMPTY (a domain makes Traefik step in and forward to port 80 -> 502). +# 2. Set PORT to a port unique on the host (9203 and 9204 are already used by the +# nursing apps; 9205 is the next free one) plus the vars in coolify.env.example. +# 3. Cloudflare Zero Trust -> Tunnels -> Public Hostname: -> +# HTTP -> 127.0.0.1:. +# +# The port is published on loopback only, so it cannot be reached directly from +# outside and bypass Cloudflare. If cloudflared runs as a container instead of on the +# host, either enable the bundled one (COMPOSE_PROFILES=tunnel + TUNNEL_TOKEN, hostname +# service http://app:) or use host networking / host.docker.internal. + +name: pubpub + +x-app-env: &app-env + NODE_ENV: production + PUBPUB_PRODUCTION: ${PUBPUB_PRODUCTION:-true} + DATABASE_URL: postgres://${POSTGRES_USER:-pubpub}:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-pubpub} + CLOUDAMQP_URL: amqp://${RABBITMQ_USER:-pubpub}:${RABBITMQ_PASSWORD:?set RABBITMQ_PASSWORD}@rabbitmq:5672/pubpub + + # --- Required secrets (server/envSchema.ts rejects startup without these) --- + JWT_SIGNING_SECRET: ${JWT_SIGNING_SECRET:?set JWT_SIGNING_SECRET} + SESSION_SECRET: ${SESSION_SECRET:?set SESSION_SECRET} + AES_ENCRYPTION_KEY: ${AES_ENCRYPTION_KEY:?set AES_ENCRYPTION_KEY} + ALTCHA_HMAC_KEY: ${ALTCHA_HMAC_KEY:?set ALTCHA_HMAC_KEY} + FIREBASE_SERVICE_ACCOUNT_BASE64: ${FIREBASE_SERVICE_ACCOUNT_BASE64:?set FIREBASE_SERVICE_ACCOUNT_BASE64} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:?set AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:?set AWS_SECRET_ACCESS_KEY} + SMTP_HOST: ${SMTP_HOST:?set SMTP_HOST} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER:?set SMTP_USER} + SMTP_PASS: ${SMTP_PASS:?set SMTP_PASS} + + # --- Required by the schema as strings, but safe to leave empty --- + AWS_BACKUP_ACCESS_KEY_ID: ${AWS_BACKUP_ACCESS_KEY_ID:-} + AWS_BACKUP_SECRET_ACCESS_KEY: ${AWS_BACKUP_SECRET_ACCESS_KEY:-} + S3_BACKUP_ENDPOINT: ${S3_BACKUP_ENDPOINT:-} + S3_BACKUP_ACCESS_KEY: ${S3_BACKUP_ACCESS_KEY:-} + S3_BACKUP_SECRET_KEY: ${S3_BACKUP_SECRET_KEY:-} + DOI_LOGIN_ID: ${DOI_LOGIN_ID:-} + DOI_LOGIN_PASSWORD: ${DOI_LOGIN_PASSWORD:-} + DOI_SUBMISSION_URL: ${DOI_SUBMISSION_URL:-https://test.crossref.org/servlet/deposit} + DATACITE_DEPOSIT_URL: ${DATACITE_DEPOSIT_URL:-https://api.test.datacite.org/dois} + ZOTERO_CLIENT_KEY: ${ZOTERO_CLIENT_KEY:-} + ZOTERO_CLIENT_SECRET: ${ZOTERO_CLIENT_SECRET:-} + FASTLY_SERVICE_ID: ${FASTLY_SERVICE_ID:-} + FASTLY_PURGE_TOKEN: ${FASTLY_PURGE_TOKEN:-} + SLACK_WEBHOOK_URL: ${SLACK_WEBHOOK_URL:-} + SENTRY_AUTH_TOKEN: ${SENTRY_AUTH_TOKEN:-} + SENTRY_ORG: ${SENTRY_ORG:-} + + # --- Optional --- + PUBPUB_LOCAL_COMMUNITY: ${PUBPUB_LOCAL_COMMUNITY:-} + CLOUDFLARE_ANALYTICS_API_TOKEN: ${CLOUDFLARE_ANALYTICS_API_TOKEN:-} + CLOUDFLARE_ZONE_TAG: ${CLOUDFLARE_ZONE_TAG:-} + CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN: ${CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN:-} + MAILCHIMP_API_KEY: ${MAILCHIMP_API_KEY:-} + BLOCKLIST_IP_ADDRESSES: ${BLOCKLIST_IP_ADDRESSES:-} + + # TLS is terminated by Cloudflare; the origin only ever sees plain HTTP. + # Skip the HTTP->HTTPS redirect (it would loop) and trust Cloudflare's + # CF-Visitor header so the `Secure` session cookie is still set. + DISABLE_SSL_REDIRECT: ${DISABLE_SSL_REDIRECT:-true} + TRUST_CF_VISITOR: ${TRUST_CF_VISITOR:-true} + +x-build: &build + context: . + dockerfile: Dockerfile + target: runner + args: + CI: "true" # skip the Sentry sourcemap upload during the image build + +services: + app: + build: *build + image: pubpub-app:${SOURCE_COMMIT:-local} + restart: unless-stopped + environment: + <<: *app-env + PORT: ${PORT:?set PORT} # app binds 0.0.0.0:$PORT (app.listen without host) + ports: + - "127.0.0.1:${PORT:?set PORT}:${PORT}" + depends_on: + db: + condition: service_healthy + rabbitmq: + condition: service_healthy + networks: [appnet] + healthcheck: + test: + - CMD-SHELL + - > + node -e "require('http').get('http://127.0.0.1:' + process.env.PORT + '/api/health', r => process.exit(r.statusCode < 400 ? 0 : 1)).on('error', () => process.exit(1))" + interval: 15s + timeout: 5s + retries: 6 + # first boot runs sequelize.sync + search trigger install + start_period: 120s + + worker: + build: *build + image: pubpub-app:${SOURCE_COMMIT:-local} + restart: unless-stopped + command: ["pnpm", "run", "workers-prod"] + environment: + <<: *app-env + depends_on: + db: + condition: service_healthy + rabbitmq: + condition: service_healthy + networks: [appnet] + + db: + image: postgres:16 + restart: unless-stopped + shm_size: "256mb" + environment: + POSTGRES_USER: ${POSTGRES_USER:-pubpub} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB:-pubpub} + volumes: + - pgdata:/var/lib/postgresql/data + networks: [appnet] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 10 + + rabbitmq: + image: rabbitmq:3.13-alpine + restart: unless-stopped + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-pubpub} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?set RABBITMQ_PASSWORD} + RABBITMQ_DEFAULT_VHOST: pubpub + volumes: + - rabbitmqdata:/var/lib/rabbitmq + networks: [appnet] + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 15s + timeout: 10s + retries: 10 + start_period: 30s + + # Optional Cloudflare Tunnel connector. Enable with COMPOSE_PROFILES=tunnel. + cloudflared: + image: cloudflare/cloudflared:latest + profiles: ["tunnel"] + restart: unless-stopped + command: tunnel --no-autoupdate run + environment: + TUNNEL_TOKEN: ${TUNNEL_TOKEN:-} + depends_on: + app: + condition: service_healthy + networks: [appnet] + +networks: + appnet: + driver: bridge + +volumes: + pgdata: + rabbitmqdata: diff --git a/coolify.env.example b/coolify.env.example new file mode 100644 index 000000000..073a68fd8 --- /dev/null +++ b/coolify.env.example @@ -0,0 +1,52 @@ +# Environment variables to set in Coolify (Resource -> Environment Variables) +# for compose.yaml. Lines marked REQUIRED make `docker compose` fail if empty. + +# --- REQUIRED: infrastructure --- +# Host loopback port the app is published on; must be unique per app on the host. +# Used: 9203 (gentle-truths), 9204 (night-shift). Next free: 9205. +# Cloudflare Tunnel public hostname -> http://127.0.0.1:. Leave Coolify Domains empty. +PORT=9205 +POSTGRES_PASSWORD= +RABBITMQ_PASSWORD= +# POSTGRES_USER=pubpub +# POSTGRES_DB=pubpub +# RABBITMQ_USER=pubpub + +# --- REQUIRED: app secrets (generate with: openssl rand -hex 32) --- +JWT_SIGNING_SECRET= +SESSION_SECRET= +AES_ENCRYPTION_KEY= +ALTCHA_HMAC_KEY= +# base64 of the Firebase service-account JSON (editor collaboration backend) +FIREBASE_SERVICE_ACCOUNT_BASE64= +# S3 bucket credentials for uploads +AWS_ACCESS_KEY_ID= +AWS_SECRET_ACCESS_KEY= +# SMTP (e.g. SES) +SMTP_HOST= +SMTP_USER= +SMTP_PASS= +# SMTP_PORT=587 + +# --- Cloudflare Tunnel --- +# Default: cloudflared runs on the Docker host -> http://127.0.0.1: (nothing to set here). +# Only if you want cloudflared inside this compose instead (service http://app:): +# COMPOSE_PROFILES=tunnel +# TUNNEL_TOKEN= + +# Defaults already assume TLS ends at Cloudflare; only change if you serve plain HTTPS directly. +# DISABLE_SSL_REDIRECT=true +# TRUST_CF_VISITOR=true + +# --- Optional --- +# Serve one community for every hostname (slug), useful for a single-site tunnel. +# PUBPUB_LOCAL_COMMUNITY= +# DOI_LOGIN_ID= +# DOI_LOGIN_PASSWORD= +# DOI_SUBMISSION_URL= +# ZOTERO_CLIENT_KEY= +# ZOTERO_CLIENT_SECRET= +# SLACK_WEBHOOK_URL= +# CLOUDFLARE_ANALYTICS_API_TOKEN= +# CLOUDFLARE_ZONE_TAG= +# CLOUDFLARE_CUSTOM_HOSTNAME_API_TOKEN= diff --git a/server/envSchema.ts b/server/envSchema.ts index b3f83db7e..7af873b8f 100644 --- a/server/envSchema.ts +++ b/server/envSchema.ts @@ -45,6 +45,9 @@ export const envSchema = z.object({ FORCE_BASE_PUBPUB: booleanish.describe('Force the base PubPub site in development/QubQub mode'), PUBPUB_READ_ONLY: booleanish.describe('Enable read-only mode, disabling all mutations'), DISABLE_SSL_REDIRECT: booleanish.describe('Disable automatic HTTP → HTTPS redirect'), + TRUST_CF_VISITOR: booleanish.describe( + 'Treat requests carrying a Cloudflare `CF-Visitor: https` header as HTTPS (Cloudflare Tunnel deployments)', + ), // ── Database ───────────────────────────────────────────────────────── DATABASE_URL: z.string().url().describe('Primary PostgreSQL connection URL'), diff --git a/server/server.ts b/server/server.ts index 079bed4f2..e74129e98 100755 --- a/server/server.ts +++ b/server/server.ts @@ -88,6 +88,20 @@ if (isProd() || isDuqDuq()) { }); } +// Cloudflare Tunnel deployments (e.g. Coolify): a reverse proxy between +// cloudflared and the app may reset X-Forwarded-Proto to `http`, which would +// drop the `Secure` session cookie. Cloudflare's CF-Visitor header carries the +// original scheme, so use it to restore the signal. +if (env.TRUST_CF_VISITOR) { + appRouter.use((req, _res, next) => { + const visitor = req.headers['cf-visitor']; + if (typeof visitor === 'string' && /"scheme"\s*:\s*"https"/.test(visitor)) { + req.headers['x-forwarded-proto'] = 'https'; + } + next(); + }); +} + if (env.NODE_ENV === 'production') { Sentry.init({ dsn: 'https://abe1c84bbb3045bd982f9fea7407efaa@sentry.io/1505439',