-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
268 lines (263 loc) · 13.3 KB
/
Copy pathdocker-compose.yml
File metadata and controls
268 lines (263 loc) · 13.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ronation
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ronation}
POSTGRES_DB: ronation
# Storage compression, on by config rather than by schema.
#
# default_toast_compression=lz4 Postgres already compresses any over-size
# column value (a guide body, a venue's JSON layout, a long description) into
# TOAST storage - this switches that from the pglz default to LZ4, which is
# faster and packs tighter, across every such value in every table without a
# single ALTER. Existing rows adopt it as they are next rewritten; a one-off
# `VACUUM FULL` in a maintenance window recompresses everything at once.
# wal_compression=on Compresses the write-ahead log, so both the disk
# churn of normal writes and the size of every base backup drop.
#
# Both ship in the official postgres:16 image. Confirm once, if you like, with:
# docker compose exec db psql -U ronation -c 'SHOW default_toast_compression'
command:
- "postgres"
- "-c"
- "default_toast_compression=lz4"
- "-c"
- "wal_compression=on"
volumes:
- db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ronation"]
interval: 5s
timeout: 5s
retries: 12
web:
build: .
# A stable image name instead of the default `<project>-web`. Nothing pulls
# this - the `build:` above means it is always built locally - so it is purely
# a convenience for running or inspecting the image by hand.
#
# There is deliberately NO `cache_from` here. It only helps when the builder's
# own cache is gone but a PULLABLE image survives, which needs a registry we do
# not use - so on a fresh machine BuildKit would try to `docker pull` this tag,
# fail ("repository does not exist"), and abort the build. The real speed-up is
# the `--mount=type=cache` lines in the Dockerfile, which are builder-local and
# pull nothing; they carry the warm npm and Next caches on their own.
image: ronation-web:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql://ronation:${POSTGRES_PASSWORD:-ronation}@db:5432/ronation?schema=public
NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000}
AUTH_SECRET: ${AUTH_SECRET:-please-change-this-secret}
ROBLOX_CLIENT_ID: ${ROBLOX_CLIENT_ID:-}
ROBLOX_CLIENT_SECRET: ${ROBLOX_CLIENT_SECRET:-}
GAME_API_KEY: ${GAME_API_KEY:-change-me}
ALLOW_DEV_LOGIN: ${ALLOW_DEV_LOGIN:-false}
PORTAL_URL: ${PORTAL_URL:-}
# Paid ticketing. BOTH must be passed through or setting them in .env does
# nothing at all - the container never sees them, env.robuxTickets stays false,
# and every priced tier renders locked with nothing on any screen to say why.
# This is the same silent-ignore trap the STRO_HOST and MERCH_HOST notes under
# `caddy` describe, on the two variables where the symptom is "we cannot sell".
#
# Defaults are "false" here as well as in lib/env.ts, so an unset value can
# never read as on at either end. See .env.example for what has to be true
# before either of these is worth setting - the game-pass one has a prerequisite
# (the OAuth app's inventory scope) that nothing in this stack can check.
ROBUX_TICKETS_ENABLED: ${ROBUX_TICKETS_ENABLED:-false}
ROBUX_GAMEPASS_ENABLED: ${ROBUX_GAMEPASS_ENABLED:-false}
# The rank ladder. One Roblox group, three thresholds - see .env.example.
# ADMIN_USERNAME/ADMIN_PASSWORD used to sit here; the shared-password door
# they opened is gone, so they are not passed and nothing reads them.
ROBLOX_GROUP_ID: ${ROBLOX_GROUP_ID:-33033115}
COMPANY_MIN_RANK: ${COMPANY_MIN_RANK:-245}
PARTNER_STAFF_RANK: ${PARTNER_STAFF_RANK:-250}
SHASHA_MIN_RANK: ${SHASHA_MIN_RANK:-200}
SHASHA_MANAGER_RANK: ${SHASHA_MANAGER_RANK:-245}
DISCORD_CLIENT_ID: ${DISCORD_CLIENT_ID:-}
DISCORD_CLIENT_SECRET: ${DISCORD_CLIENT_SECRET:-}
DISCORD_MANAGER_IDS: ${DISCORD_MANAGER_IDS:-}
DISCORD_STAFF_IDS: ${DISCORD_STAFF_IDS:-}
# The two Discord webhooks: the staff inbox (applications, enquiries, data
# requests, ticket reservations) and the members' channel a show is posted to
# when it goes live. See .env.example for why they are separate.
#
# DISCORD_WEBHOOK_URL was MISSING from this block until the announcement
# webhook was added beside it, which meant lib/notify.ts had been a silent
# no-op in Docker since it was written: the URL sat in .env, the container
# never saw it, notify() found nothing configured and returned - exactly the
# behaviour it has when you deliberately leave it blank, and therefore
# indistinguishable from it. Precisely the silent-ignore trap the ROBUX_
# note above describes.
#
# NOTE the per-partner overrides cannot be listed here: the key is built from
# a partner slug at runtime (DISCORD_WEBHOOK_URL_<SLUG>), so compose has no
# name to interpolate. Adding a partner's own channel means adding a literal
# line to this block as well as to .env.
DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL:-}
DISCORD_ANNOUNCE_WEBHOOK_URL: ${DISCORD_ANNOUNCE_WEBHOOK_URL:-}
# Reservation confirmation emails (Resend). Same silent-ignore trap as
# DISCORD_WEBHOOK_URL above if this line is missing: the key sits in .env,
# the container never sees it, sendTicketReservationEmail() finds nothing
# configured and no-ops - indistinguishable from deliberately leaving it
# blank. See .env.example.
RESEND_API_KEY: ${RESEND_API_KEY:-}
EMAIL_FROM: ${EMAIL_FROM:-RO. Nation LIVE <tickets@ronation.live>}
# DEPRECATED - no longer read by anything. These fed prisma/seed.ts, which
# created the first PartnerMember row on every boot. That seed is gone; seat a
# partner's first owner with `npm run partner:member` instead (see .env.example
# and scripts/grant-partner-owner.ts). Kept here only so a .env that still sets
# them does not error; safe to drop.
STRO_OWNER_ROBLOX_ID: ${STRO_OWNER_ROBLOX_ID:-}
STRO_OWNER_NAME: ${STRO_OWNER_NAME:-}
# Where uploaded images are written. Must match the volume mount below and
# the root Caddy serves them from.
UPLOAD_DIR: /app/uploads
# Where files that must NOT be public are written: brand guideline PDFs,
# unreleased artwork. Its volume is deliberately absent from Caddy's mounts
# below - that omission IS the access control. See lib/uploads.ts.
PRIVATE_UPLOAD_DIR: /app/private-uploads
TZ: ${TZ:-UTC}
volumes:
# Uploaded images. A named volume, so they survive `docker compose up
# --build` - a bind to the source tree would be wiped by a redeploy, and
# baking them into the image would mean every upload needs a rebuild.
#
# BACK THIS UP. It is the one piece of state that is not in Postgres, so a
# database dump alone does NOT restore the site's artwork.
- uploads:/app/uploads
# The gated files. BACK THIS UP TOO - same reasoning, and it is now two
# volumes a database dump does not restore, not one.
#
# Nothing serves this directory. It is read only by app/files/[id], which
# checks the caller's session before it opens a single byte.
- private-uploads:/app/private-uploads
# Bound to loopback: the public entrypoint is Caddy on 443. Publishing this
# on 0.0.0.0 would let anyone reach the app over plain HTTP, bypassing TLS.
ports:
- "127.0.0.1:${WEB_PORT:-3000}:3000"
healthcheck:
test:
[
"CMD",
"node",
"-e",
"fetch('http://localhost:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
# Daily maintenance. Reuses the web image (so it has tsx, the Prisma client and a
# DATABASE_URL) and runs `npm run cull` once a day - scripts/cull.ts - to delete the
# throwaway rows that would otherwise grow forever: spent SSO tickets, ended
# rate-limit windows, expired seat holds. No ports. Change the hour with CULL_HOUR
# (UTC); see docker-cron.sh.
cron:
build: .
image: ronation-web:latest
restart: unless-stopped
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgresql://ronation:${POSTGRES_PASSWORD:-ronation}@db:5432/ronation?schema=public
CULL_HOUR: ${CULL_HOUR:-4}
TZ: ${TZ:-UTC}
volumes:
# The cull used to talk only to the database. It now also deletes BYTES -
# survey attachments that were uploaded and never submitted - and bytes need
# the volume they live on.
#
# Without this the container falls back to /app/private-uploads, which is the
# empty directory baked into the image rather than the volume `web` writes to.
# Every unlink would miss, be swallowed by its own catch, and the row would be
# deleted anyway - leaving the file on the real volume with nothing left that
# knows where it is. Silent, permanent, and reported in the log as a success.
- private-uploads:/app/private-uploads
entrypoint: ["./docker-cron.sh"]
# TLS terminator. Gets and renews Let's Encrypt certificates automatically for
# both hostnames, and redirects http → https. See ./Caddyfile.
#
# Joins two networks: `default` (to reach `web` and the other services in this
# project) and the shared external `edge` network (to reach the RNL support
# dashboard at `rnl-dashboard:6969`). Both are declared at the bottom of the file.
caddy:
networks:
- default
- edge
image: caddy:2-alpine
restart: unless-stopped
depends_on:
- web
environment:
SITE_HOST: ${SITE_HOST:-ronation.live}
PORTAL_HOST: ${PORTAL_HOST:-portal.ronation.live}
# The partner PROGRAMME - singular "partner", and RNL's own. Not a partner site;
# see the block about it in the Caddyfile. It holds sessions, so it also needs an
# entry in knownOrigins() (src/lib/sso.ts) or partners can read the public half and
# never sign in to their own area.
PARTNER_HOST: ${PARTNER_HOST:-partner.ronation.live}
SURVEY_HOST: ${SURVEY_HOST:-survey.ronation.live}
# The sign-in host. Caddy must terminate TLS for it or nobody can sign in to
# anything - see the note at the top of the Caddyfile.
AUTHORISE_HOST: ${AUTHORISE_HOST:-authorise.ronation.live}
# The shop. TWO names, and both need a certificate: merch serves it and shop 301s
# to merch (src/middleware.ts) - and a redirect still has to be spoken over HTTPS,
# so a `shop` host with no cert fails with a TLS error instead of forwarding.
#
# Same trap as STRO_HOST above, which is why that comment is there: the Caddyfile
# defaults these on its address line, so leaving them out of here works - right up
# until somebody sets MERCH_HOST in .env and finds it silently ignored.
MERCH_HOST: ${MERCH_HOST:-merch.ronation.live}
SHOP_HOST: ${SHOP_HOST:-shop.ronation.live}
# The Caddyfile reads this on its address line. It has a default there, so
# the omission was invisible - but STRO_HOST in .env was being ignored.
STRO_HOST: ${STRO_HOST:-sleeptokenro.ronation.live}
# Same trap, same reason. DNS already resolves; this is what gets Caddy to
# actually ask Let's Encrypt for it and terminate TLS on the name.
DROPZONE_HOST: ${DROPZONE_HOST:-dropzone.ronation.live}
# CNĒCT is a separate Compose project on the shared edge network.
CNECT_HOST: ${CNECT_HOST:-cnect.ronation.live}
ACME_EMAIL: ${ACME_EMAIL:-admin@ronation.live}
ports:
- "80:80"
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data # issued certificates - must persist across redeploys
- caddy-config:/config
# The same volume the app writes uploads to, mounted READ-ONLY: Caddy's job
# here is to hand the files out, and it has no business being able to change
# or delete one.
- uploads:/srv/uploads:ro
#
# `private-uploads` is NOT mounted here, and that is not an oversight - it is
# the mechanism. Caddy serves /uploads/* straight off the volume above,
# before a request has been anywhere near the app, so nothing it serves can
# ever check a session. Keeping the gated files out of this container's
# filesystem entirely means no Caddyfile edit can expose them by accident and
# no traversal out of /srv/uploads can reach them.
#
# DO NOT ADD IT.
networks:
# This project's private network (created automatically by compose). All the
# services above share it; only `caddy` also joins `edge`.
default:
# Shared network that the RNL support stack also joins, so this Caddy can
# reverse-proxy support.ronation.live → rnl-dashboard:6969. Declared external
# because the RNL stack (a separate compose project) does not own it either.
# Create it once on the host: docker network create edge
edge:
external: true
volumes:
db-data:
caddy-data:
caddy-config:
uploads:
private-uploads: