-
Notifications
You must be signed in to change notification settings - Fork 6
Expand file tree
/
Copy pathdocker-compose.yaml
More file actions
293 lines (283 loc) · 13 KB
/
Copy pathdocker-compose.yaml
File metadata and controls
293 lines (283 loc) · 13 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
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
# Bounded container logs: the default json-file driver never rotates, so a chatty
# or crash-looping container can fill the host disk. Cap each container's logs at
# 3 x 10MB; every long-running service references this via *default-logging.
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
# Shared build config for the app image (web + celery). PUID/PGID must reach the
# Dockerfile's ARGs so the in-container app user matches the host owner of the
# bind-mounted mediafiles/logs dirs -- update.sh's ensure_permissions chowns those
# to the same .env values, so declaring the ARG without wiring it here would let
# the two silently diverge whenever PUID/PGID != 1000.
x-app-build: &app-build
context: .
args:
PUID: ${PUID-1000}
PGID: ${PGID-1000}
# NOTE on container_name: the fixed names (django, db, redis, ...) are intentional.
# The entire ops workflow -- CLAUDE.md, update-packages.sh, cron/docs -- runs
# `docker exec django ...` / `docker exec redis ...` against them. Fixed names do
# prevent `docker compose up --scale` and would collide if two checkouts ran on one
# host, but this is a single-instance deploy where stable, greppable names win.
services:
web:
build: *app-build
container_name: django
logging: *default-logging
volumes:
- .:/home/app/web
# collectstatic output lives in a dedicated volume, NOT bind-mounted onto
# the source tree. Previously this was `./auctions/static:.../staticfiles`,
# which pointed STATIC_ROOT at the app's own source-static dir -- so
# collectstatic dumped third-party statics (admin/, summernote/, ...) into
# git and update.sh needed a destructive `git restore .`. The source dir
# (auctions/static) is still visible via the repo mount above for Django's
# AppDirectoriesFinder to collect FROM.
- staticfiles:/home/app/web/staticfiles
- ./mediafiles:/home/app/web/mediafiles
# Django's rotating log files (settings.py LOG_DIR) -- on the host so they
# survive container recreation and can be grepped without docker exec.
- ./logs:/home/logs
expose:
- 8000
env_file: .env
depends_on:
db:
condition: service_healthy
redis:
condition: service_started
restart: always
# HTTP check: proves a worker actually served a request. A bare TCP connect is
# not enough -- the gunicorn master binds the socket before forking, so TCP
# succeeds even while every worker crash-loops on an import error. Any HTTP
# response (even a 4xx/5xx) counts as alive: 127.0.0.1 is in ALLOWED_HOSTS, and
# http.client never follows redirects so the check can't wander off-box.
# start_period is generous: entrypoint.sh runs migrate + collectstatic before
# gunicorn binds.
healthcheck:
test:
- CMD-SHELL
- |
python3 - <<'PY'
import http.client
conn = http.client.HTTPConnection("127.0.0.1", 8000, timeout=5)
conn.request("GET", "/")
conn.getresponse()
PY
interval: 15s
timeout: 10s
retries: 5
start_period: 90s
celery_worker:
build: *app-build
container_name: celery_worker
logging: *default-logging
entrypoint: ["sh", "./celery_worker_entrypoint.sh"]
volumes:
- .:/home/app/web
- ./logs:/home/logs
env_file: .env
environment:
# Own log files (root-celery.log/django-celery.log): RotatingFileHandler is
# not multiprocess-safe across services sharing the ./logs mount (settings.py).
- LOG_SERVICE_NAME=celery
depends_on:
- db
- redis
restart: always
celery_beat:
build: *app-build
container_name: celery_beat
logging: *default-logging
entrypoint: ["sh", "./celery_beat_entrypoint.sh"]
volumes:
- .:/home/app/web
- ./logs:/home/logs
env_file: .env
environment:
- LOG_SERVICE_NAME=beat # own log files; see celery_worker
depends_on:
- db
- redis
restart: always
test:
build:
context: .
dockerfile: Dockerfile
target: test
volumes:
- .:/home/app/web
entrypoint: [/bin/bash, /home/app/web/.github/scripts/test-and-lint.sh]
env_file: .env
profiles: [test]
depends_on:
db:
condition: service_healthy
db:
container_name: db
logging: *default-logging
# Track a single release series (security patches flow automatically) instead of
# :latest, which jumps release series on routine pulls and leaves system tables
# behind unless mariadb-upgrade runs. 11.4 is an LTS series (maintained to 2029).
# If an environment's data volume was created under a newer series, set
# MARIADB_VERSION in .env to that series -- MariaDB can't downgrade a data dir.
image: mariadb:${MARIADB_VERSION-11.4}
restart: always
# Connection limits and timeouts. Peak concurrency is NOT just the process count:
# each of the 8 async gunicorn workers runs sync ORM code in a per-worker threadpool,
# and with CONN_MAX_AGE=0 every in-flight request holds its own connection -- so an
# auction-end spike can legitimately hold a few hundred connections at once (plus
# celery, beat, and ops sessions). 512 covers that peak while staying BOUNDED, which
# is what makes it a leak detector: a real leak shows up as Max_used_connections
# climbing toward the cap long before it hurts. (The old max_connections=10000 just
# hid leaks and reserved huge per-thread memory.)
command:
- "--max_connections=512" # bounded: covers peak fan-out AND surfaces leaks (watch Max_used_connections)
- "--max_allowed_packet=16M"
# wait_timeout: how long an IDLE connection is kept before the server reaps it.
# The app never leaves a connection idle by accident -- HTTP is fresh-per-request,
# Channels runs close_old_connections around every consumer dispatch, and celery's
# Django fixup does the same at task boundaries -- so nothing legitimately idles for
# long and there is no unbounded-leak path (max_connections above is the backstop).
# A short timeout (we tried 3600s) reaps live-but-idle connections in edge/long-task
# cases and surfaces as user-visible "server has gone away" 500s across many paths.
# 30 days = never reap a live connection, while still finite so a true zombie
# eventually clears (unlike the old ~10-year / 315M value, which was effectively
# infinite and hid stuck connections forever).
- "--wait_timeout=2592000" # 30 days
- "--interactive_timeout=3600" # keep interactive/CLI sessions alive an hour for ops
- "--open_files_limit=100000" # legitimate: many tables x connections
- "--net_read_timeout=120" # finite (was 315M) so a stalled client frees its connection
- "--net_write_timeout=180" # finite (was 315M); headroom for slow large result streaming
environment:
MYSQL_DATABASE: ${DATABASE_NAME-auctions}
MYSQL_USER: ${DATABASE_USER-mysqluser}
MYSQL_PASSWORD: ${DATABASE_PASSWORD-unsecure}
MYSQL_ROOT_PASSWORD: ${DATABASE_ROOT_PASSWORD-unsecure}
# Run mariadb-upgrade on startup after a server version change, so system
# tables (mysql.proc etc.) never drift out of step with the running server.
MARIADB_AUTO_UPGRADE: 1
expose:
- ${DATABASE_PORT-3306}
volumes:
- mariadb_data:/var/lib/mysql
- ./db-init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "mariadb-admin ping -h localhost --password=\"$${MYSQL_ROOT_PASSWORD}\" --silent || exit 1"]
interval: 5s
timeout: 5s
retries: 60
start_period: 30s # mariadb typically starts in under 60s; CI uses a separate 240s wait loop
redis:
container_name: redis
logging: *default-logging
# Pin a release series like the db (security patches within 7.4 flow on rebuild,
# no surprise major jumps). Redis is the Celery broker + result backend + cache
# + channels pub/sub layer.
image: redis:${REDIS_VERSION-7.4}
expose:
- 6379
volumes:
# The official redis image persists to /data. The old mount at
# /bitnami/redis/data was a leftover from a bitnami example, so the volume
# was never written and the broker queue/results were wiped on every
# container recreate. Enable AOF (appendfsync everysec) so the broker queue
# survives restarts with at most ~1s of loss.
- redis_data:/data
restart: always
command: >
redis-server
--requirepass ${REDIS_PASSWORD-unsecure}
--appendonly yes
--appendfsync everysec
nginx:
container_name: nginx
logging: *default-logging
# Pin the tag instead of :latest so a routine rebuild/pull can't drag in a
# swag/nginx release that reorganizes its config includes and breaks the site.
# NGINX_IMAGE selects dev (nginx) vs prod (lscr.io/linuxserver/swag); NGINX_TAG
# pins the version. Both tags still receive rebuilds on `docker compose pull`
# (nginx 1.27 patch rebuilds; swag republishes version tags with newer -ls
# builds), so security patches flow without surprise major jumps. The 1.27
# default only exists for the dev nginx image -- when NGINX_IMAGE is set to
# swag, NGINX_TAG MUST be set in .env to the swag release the host runs
# (update.sh refuses to deploy otherwise). Bump deliberately.
image: ${NGINX_IMAGE-nginx}:${NGINX_TAG-1.27}
volumes:
# the root conf file is set for dev, but for production use:
# NGINX_CONF='./nginx.prod.conf'
# NGINX_CONF_LOCATION='/config/nginx/site-confs/default.conf'
# nginx.prod.conf is gitignored and rendered by update.sh. If compose runs
# before it exists, Docker creates the mount source as a DIRECTORY and swag
# silently serves its default config -- always deploy via ./update.sh
# (render_nginx_domain both renders and self-heals the directory case).
- ${NGINX_CONF-./nginx.dev.conf}:${NGINX_CONF_LOCATION-/etc/nginx/nginx.conf}
- ./swag:/config
- ./nginx_fishauctions.conf:/etc/nginx/nginx_fishauctions.conf:ro
# Serve the collected statics from the shared volume that web writes to
# (was a bind mount onto the source tree; see the web service for why).
- staticfiles:/home/app/web/staticfiles:ro
- ./mediafiles:/home/app/web/mediafiles:ro
ports:
- ${HTTP_PORT-80}:80
- ${SSL_PORT-443}:443 # this is only used in production
restart: always
# Deliberately NOT condition: service_healthy. Gating nginx on web's health
# removes the graceful-degradation path: if web can't become healthy (e.g. a
# failed migration crash-looping the container), a service_healthy dependency
# means nginx can never start or be recreated -- the public endpoint becomes
# connection-refused and swag's cert renewal stops, instead of nginx serving
# 502s until web recovers. nginx tolerates an absent upstream fine, so it only
# needs start ordering here; web's healthcheck still reports in `ps`/`--wait`.
depends_on:
web:
condition: service_started
# NET_ADMIN is required by the prod swag image only: its bundled fail2ban edits
# iptables to ban abusive IPs. The dev plain-nginx image never uses the
# capability (it's inert there, a minor over-grant on a local container).
# Compose can't conditionally drop a single cap_add entry via env, and removing
# it would break swag's fail2ban in prod, so it stays for both. NOTE: the prod
# config path (NGINX_CONF=./nginx.prod.conf + swag ssl.conf includes) is only
# exercised in production -- there's no dev harness for it, so change
# nginx.prod.conf / nginx_fishauctions.conf carefully.
cap_add:
- NET_ADMIN
environment: # none of these get used in development
- PUID=${PUID-1000}
- PGID=${PGID-1000}
- TZ=Etc/UTC
- URL=${SITE_DOMAIN}
- VALIDATION=http
- EMAIL=${ADMIN_EMAIL}
# ACME CA swag requests certs from. Empty = swag's default (Let's Encrypt).
# Prod runs zerossl since the 2026-07-20 incident: swag's init revoked every
# LE cert for the domain and new LE issuance was rate-limited for a week
# ("new certificates per exact set of identifiers"). Value lives in .env
# (untracked) so update.sh's `git restore .` can't strip it on deploy.
- CERTPROVIDER=${CERTPROVIDER-}
selenium:
container_name: selenium
logging: *default-logging
image: selenium/standalone-chrome:latest
shm_size: 2gb
expose:
- 4444
profiles: [selenium]
environment:
- SE_NODE_MAX_SESSIONS=4
- SE_NODE_SESSION_TIMEOUT=300
healthcheck:
test: ["CMD-SHELL", "curl -sSL http://localhost:4444/status | grep -qE '\"ready\"[[:space:]]*:[[:space:]]*true'"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
volumes:
redis_data:
mariadb_data:
# collectstatic output (STATIC_ROOT). A named volume rather than a bind mount
# onto ./auctions/static, so collected third-party statics never land in the
# source tree / git. Repopulated by collectstatic on container start.
staticfiles: