Repository navigation
Expand file tree
/
Copy pathdocker-compose.prod.yml
More file actions
211 lines (204 loc) · 8.6 KB
/
Copy pathdocker-compose.prod.yml
File metadata and controls
211 lines (204 loc) · 8.6 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
# Production front-of-house: built web bundle via nginx + non-reload API. See README.
services:
api:
# Published by .github/workflows/publish.yml on a version tag (that
# workflow only ever pushes `latest` + the pushed tag itself — there is no
# floating major/minor alias) — so pin to a real released version here,
# never `:latest`, and require the operator to say which one.
# `docker compose -f docker-compose.prod.yml pull` skips the source build;
# `up` still builds locally when the image is absent.
image: ghcr.io/andrewctf/velocity-api:${VELOCITY_VERSION:?set VELOCITY_VERSION to a published tag, e.g. v1.0.1}
build:
context: .
dockerfile: infra/docker/api.Dockerfile
runtime: runc
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
pids_limit: 512
mem_limit: 8g
# Everything the process writes goes to a real mount: /srv/data is the
# named volume below, /tmp is where the sidecar log files
# (adsb/ais/llamacpp/vllm/mavlink/browser-fetch) and imagery/ondemand.py's
# tempfile.mkdtemp() land. Proven booted + serving /api/health under this
# exact read_only+tmpfs shape — see this wave's report for the command.
read_only: true
tmpfs:
- /tmp:size=512m
networks:
- front
env_file:
# Optional: the platform boots keyless. `cp .env.example .env` to add keys.
- path: .env
required: false
environment:
CORS_ORIGINS: http://localhost:8080
# Archive profile: lift the retention clamp and size the replay store to
# a disk budget instead of the default in-memory-sized byte cap, so the
# osint_data volume below actually holds a multi-week history/AIS+ADS-B
# archive instead of the bounded live buffer.
ARCHIVE_MODE: 1
HISTORY_DISK_BUDGET_GB: 5
# The rate limiter only believes X-Forwarded-For from a peer inside this
# list (apps/api/app/ratelimit.py `_trusted_nets`, CIDR-aware). Only
# nginx (on the `front` network below) ever talks to api directly, so
# trusting the whole compose subnet — not just nginx's one IP — is safe
# and survives nginx being recreated with a new address.
# Multipart uploads spool to TMPDIR before the handler's size cap runs;
# recon accepts up to 4 GB, which would overflow the 512 MB /tmp tmpfs.
# Spool on the data volume instead (tempfile falls back to /tmp if the
# directory is missing on a volume created before this image).
TMPDIR: /srv/data/.tmp
TRUSTED_PROXIES: 172.28.0.0/24,127.0.0.1,::1
# History archive on TimescaleDB (service below). The password lives in
# .env as TIMESCALE_PASSWORD and reaches the API only through this DSN;
# the database is on the internal `front` network with no published port.
HISTORY_PG_DSN: postgresql://velocity:${TIMESCALE_PASSWORD:?set TIMESCALE_PASSWORD in .env}@timescale:5432/velocity
depends_on:
timescale:
condition: service_healthy
# No source bind-mount and no --reload: the image is the deployable unit.
command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health').read()"]
interval: 5s
timeout: 3s
retries: 10
# Secrets as files instead of environment variables (which `docker
# inspect` shows and child processes inherit). Uncomment together with the
# top-level `secrets:` block at the end of this file; each file lands at
# /run/secrets/<name>. The api reads them only once its settings model
# loads /run/secrets (pydantic secrets_dir); until then keep using .env.
# secrets:
# - api_key
# - supabase_jwt_secret
# - byok_enc_key
#
# Off-host logs. A compromised host can rewrite logs it keeps itself, so
# ship stdout somewhere else. Any Docker logging driver works (syslog,
# fluentd, gelf, loki plugin); the same block fits the nginx service. The
# default json-file driver keeps logs on this machine only.
# logging:
# driver: syslog
# options:
# syslog-address: "tcp+tls://logs.example.internal:6514"
# tag: "velocity-api"
volumes:
# Named volume (matching web_dist's style below) so the SQLite stores
# (history/ontology/foundry/workflows/alert-rules) and tile cache under
# WORKDIR /srv survive `docker compose down` and image rebuilds.
- osint_data:/srv/data
timescale:
# Position-history archive (compressed hypertable). Pinned by digest like
# nginx; no published port; data on its own named volume.
image: timescale/timescaledb:2.17.2-pg16@sha256:4e459e217f00cbb09920c34d245501e63427e6767a495de57ce76823ff280f12
runtime: runc
# Runs as the image's postgres uid from the start with every capability
# dropped (proven booting + CREATE EXTENSION timescaledb 2026-09-17); the
# named volume is initialised from the image's mountpoint ownership.
user: "70:70"
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
pids_limit: 256
mem_limit: 2g
shm_size: 1g
networks:
- front
environment:
POSTGRES_USER: velocity
POSTGRES_DB: velocity
POSTGRES_PASSWORD: ${TIMESCALE_PASSWORD:?set TIMESCALE_PASSWORD in .env}
volumes:
- timescale_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U velocity -d velocity"]
interval: 5s
timeout: 3s
retries: 20
web-build:
# One-shot builder: compiles the static web bundle into the shared web_dist
# volume, then exits. nginx serves the produced files. Same tag discipline
# as api — this is a build tool, not a long-running service, but it still
# must not silently drift to whatever `latest` happens to be today.
image: ghcr.io/andrewctf/velocity-web:${VELOCITY_VERSION:?set VELOCITY_VERSION to a published tag, e.g. v1.0.1}
build:
context: .
dockerfile: infra/docker/web.Dockerfile
runtime: runc
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
pids_limit: 512
mem_limit: 4g
# NOT read_only: vite writes node_modules/.vite-temp during the build
# (measured: EROFS mkdir with --read-only). The container exits as soon as
# the bundle is written, and it runs with no capabilities as uid 10001.
networks:
- front
command: ["sh", "-c", "node_modules/.bin/tsc --noEmit && node_modules/.bin/vite build"]
volumes:
- web_dist:/srv/apps/web/dist
restart: "no"
nginx:
build:
context: .
dockerfile: infra/docker/nginx.Dockerfile
runtime: runc
# Unprivileged like api and web-build: the stock image's `nginx` user
# (uid 101), no capabilities, read-only root. nginx.prod.conf listens on
# 8080 and keeps its pid file and temp paths on the /tmp tmpfs; uploads and
# proxied responses stream rather than spool there (nginx.prod.conf says how).
user: nginx
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
pids_limit: 256
read_only: true
tmpfs:
- /tmp:size=64m
ports:
# Loopback only: TLS terminates at a reverse proxy on the host, which
# fronts this port with a real certificate. An operator who
# wants nginx to face the internet directly widens this deliberately;
# it is not this compose file's default because it would ship a plain
# HTTP origin with no TLS of its own.
- "127.0.0.1:8080:8080"
networks:
- front
volumes:
- ./infra/nginx/nginx.prod.conf:/etc/nginx/nginx.conf:ro
# nocopy + wait for the build: otherwise nginx can reach the empty volume
# first and seed it with its own root-owned default page, the uid-10001
# `vite build` then fails EACCES unlinking 50x.html, and the site serves
# "Welcome to nginx!" forever (caught by scripts/smoke-release.cjs,
# 2026-09-13).
- type: volume
source: web_dist
target: /usr/share/nginx/html
read_only: true
volume:
nocopy: true
depends_on:
api:
condition: service_started
web-build:
condition: service_completed_successfully
networks:
front:
ipam:
config:
- subnet: 172.28.0.0/24
# Pairs with the commented `secrets:` list on the api service. Files are read
# from the host at `up`; keep them mode 0400 and out of the repository.
# secrets:
# api_key:
# file: ./secrets/api_key
# supabase_jwt_secret:
# file: ./secrets/supabase_jwt_secret
# byok_enc_key:
# file: ./secrets/byok_enc_key
volumes:
web_dist: {}
osint_data: {}
timescale_data: {}