Repository navigation
Expand file tree
/
Copy pathcompose.prod.yml
More file actions
161 lines (153 loc) · 6.59 KB
/
Copy pathcompose.prod.yml
File metadata and controls
161 lines (153 loc) · 6.59 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
# Production stack for one VPS. Run from the repository root:
#
# make prod-init # once: creates deploy/.env.prod with a generated DB password
# make prod-up # validates the env, then `up -d --wait`
#
# After that nothing on the host is touched by hand. CI publishes ${APP_IMAGE}:latest on every push to main;
# Watchtower notices the new digest within WATCHTOWER_POLL_INTERVAL seconds and replaces the app replicas one at a
# time. Caddy re-resolves the `app` service name every 5 seconds, so a replaced replica joins the rotation on its own.
#
# Only Caddy publishes ports (80/443). Postgres listens on the loopback interface for SSH tunnels. The management
# port (8081) is neither published nor proxied.
#
# Memory budget (mem_limit): caddy 64m + app 2×512m + db 384m + watchtower 48m ≈ 1.5 GB; every brick's README states
# what it adds. Re-check this sum whenever a service is added or a limit raised.
name: ${COMPOSE_PROJECT_NAME:?run make prod-init (sets COMPOSE_PROJECT_NAME from the git remote)}
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
caddy:
image: caddy:2-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
environment:
DOMAIN: ${DOMAIN:?set DOMAIN in deploy/.env.prod (public hostname; DNS must already point here)}
ACME_EMAIL: ${ACME_EMAIL:?set ACME_EMAIL in deploy/.env.prod (Let's Encrypt account e-mail)}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
# Certificates live here; losing this volume means re-requesting them and meeting Let's Encrypt rate limits.
- caddy_data:/data
- caddy_config:/config
networks: [edge]
mem_limit: 64m
logging: *default-logging
labels:
com.centurylinklabs.watchtower.enable: "false"
app:
image: ${APP_IMAGE:?run make prod-init (sets APP_IMAGE from the git remote)}:${APP_IMAGE_TAG:-latest}
restart: unless-stopped
# No container_name: it would collide across replicas.
deploy:
replicas: ${APP_REPLICAS:-2}
# Spring's graceful shutdown gets SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE to drain; this must be longer.
stop_grace_period: 45s
depends_on:
db:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: prod
APP_NAME: ${COMPOSE_PROJECT_NAME}
APP_ENV: ${APP_ENV:-prod}
# Actuator on its own port: never published by this file, never proxied by the Caddyfile.
MANAGEMENT_PORT: "8081"
SPRING_LIFECYCLE_TIMEOUT_PER_SHUTDOWN_PHASE: 30s
DB_URL: jdbc:postgresql://db:5432/${POSTGRES_DB:-app}
DB_USERNAME: ${POSTGRES_USER:-app}
DB_PASSWORD: ${POSTGRES_PASSWORD:?run make prod-init (generates POSTGRES_PASSWORD)}
DB_POOL_SIZE: ${DB_POOL_SIZE:-5}
healthcheck:
test: ["CMD-SHELL", "curl -sf http://127.0.0.1:8081/actuator/health | grep -q '\"status\":\"UP\"'"]
interval: 30s
timeout: 10s
retries: 3
# Liquibase runs at boot and the second replica waits on its lock; give both time before counting failures.
start_period: 90s
networks: [edge, data]
mem_limit: ${APP_MEM_LIMIT:-512m}
logging: *default-logging
labels:
com.centurylinklabs.watchtower.enable: "true"
# Executed by Watchtower inside the NEW replica after start; returns only once actuator reports UP, so the
# second replica is stopped only when the first is serving. Timeout in minutes.
com.centurylinklabs.watchtower.lifecycle.post-update: >-
sh -c 'for i in $(seq 1 90); do curl -sf http://127.0.0.1:8081/actuator/health | grep -q "\"status\":\"UP\"" && exit 0; sleep 2; done; echo "replica not healthy after 180s" >&2; exit 1'
com.centurylinklabs.watchtower.lifecycle.post-update-timeout: "4"
db:
image: postgres:17-alpine
restart: unless-stopped
# Loopback only. Docker's iptables rules bypass the host firewall for a 0.0.0.0 bind, so this is the guard.
ports:
- "127.0.0.1:${POSTGRES_HOST_PORT:-5432}:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_USER: ${POSTGRES_USER:-app}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?run make prod-init (generates POSTGRES_PASSWORD)}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
# Sized for a small VPS; max_connections must exceed APP_REPLICAS × DB_POOL_SIZE plus a few for psql/backup.
command:
- postgres
- -c
- max_connections=50
- -c
- shared_buffers=128MB
- -c
- work_mem=4MB
- -c
- maintenance_work_mem=64MB
- -c
- effective_cache_size=256MB
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app} -d ${POSTGRES_DB:-app}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks: [data]
mem_limit: 384m
logging: *default-logging
labels:
com.centurylinklabs.watchtower.enable: "false"
watchtower:
image: containrrr/watchtower:1.7.1
restart: unless-stopped
environment:
# Watchtower 1.7.1 negotiates Docker API 1.25; Docker 25+ refuses anything below 1.44 and Watchtower then
# silently deploys nothing. This pin is what makes unattended deploys actually happen.
DOCKER_API_VERSION: "1.44"
# Only containers labelled watchtower.enable=true (the app) are ever touched.
WATCHTOWER_LABEL_ENABLE: "true"
# Replace replicas one at a time so Caddy always has a healthy upstream.
WATCHTOWER_ROLLING_RESTART: "true"
WATCHTOWER_CLEANUP: "true"
WATCHTOWER_INCLUDE_STOPPED: "false"
# Longer than the app's stop_grace_period, so a draining replica is not killed early.
WATCHTOWER_TIMEOUT: 60s
# Run the app's post-update hook (below) inside each new replica and wait for it before touching the next one.
# Without this, rolling restart is stop→start→next and both replicas are down for one JVM boot.
WATCHTOWER_LIFECYCLE_HOOKS: "true"
WATCHTOWER_POLL_INTERVAL: ${WATCHTOWER_POLL_INTERVAL:-60}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
# Registry credentials, only for a private GHCR package: the config.json written by `docker login ghcr.io`.
# Must be the file itself; a directory here leaves Watchtower without credentials.
- ${DOCKER_CONFIG_FILE:-/dev/null}:/config.json:ro
networks: [edge]
mem_limit: 48m
logging: *default-logging
labels:
com.centurylinklabs.watchtower.enable: "false"
networks:
# Only `app` joins both: a compromised proxy cannot reach the database.
edge:
data:
volumes:
postgres_data:
caddy_data:
caddy_config: