Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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 ----
Expand Down
172 changes: 172 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -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: <your domain> ->
# HTTP -> 127.0.0.1:<PORT>.
#
# 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:<PORT>) 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:
52 changes: 52 additions & 0 deletions coolify.env.example
Original file line number Diff line number Diff line change
@@ -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:<PORT>. 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:<PORT> (nothing to set here).
# Only if you want cloudflared inside this compose instead (service http://app:<PORT>):
# 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=
3 changes: 3 additions & 0 deletions server/envSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
14 changes: 14 additions & 0 deletions server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down