2026-09-03 08:13:10 +00:00
|
|
|
# =============================================================================
|
|
|
|
|
# Production overlay.
|
|
|
|
|
#
|
|
|
|
|
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
|
|
|
|
#
|
|
|
|
|
# What it changes:
|
|
|
|
|
# * Elasticsearch authentication is switched on and every client is given
|
|
|
|
|
# credentials.
|
|
|
|
|
# * Kibana is no longer published; reach it through an SSH tunnel or an
|
|
|
|
|
# authenticating reverse proxy.
|
|
|
|
|
# * Containers get CPU/memory limits and no longer seed the dataset on boot.
|
|
|
|
|
# * The API is fronted by HTTPS (SECURE_SSL_REDIRECT + HSTS come from
|
|
|
|
|
# config/settings/prod.py).
|
|
|
|
|
#
|
|
|
|
|
# TLS between the containers is intentionally out of scope here: in a real
|
|
|
|
|
# deployment the cluster sits behind a service mesh or an internal load
|
|
|
|
|
# balancer that terminates it. See docs/SECURITY.md.
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
services:
|
|
|
|
|
elasticsearch:
|
|
|
|
|
environment:
|
|
|
|
|
xpack.security.enabled: "true"
|
|
|
|
|
ELASTIC_PASSWORD: ${ELASTIC_PASSWORD:?ELASTIC_PASSWORD is required in production}
|
|
|
|
|
healthcheck:
|
|
|
|
|
test:
|
|
|
|
|
- "CMD-SHELL"
|
|
|
|
|
- "curl -fsS -u elastic:${ELASTIC_PASSWORD} 'http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=5s' >/dev/null || exit 1"
|
|
|
|
|
deploy:
|
|
|
|
|
resources:
|
|
|
|
|
limits: { memory: 2g }
|
|
|
|
|
|
|
|
|
|
logstash:
|
|
|
|
|
environment:
|
|
|
|
|
# The Elasticsearch output reads its credentials from the URL.
|
|
|
|
|
ELASTICSEARCH_HOSTS: http://elastic:${ELASTIC_PASSWORD}@elasticsearch:9200
|
|
|
|
|
deploy:
|
|
|
|
|
resources:
|
|
|
|
|
limits: { memory: 1g }
|
|
|
|
|
|
|
|
|
|
kibana:
|
|
|
|
|
environment:
|
|
|
|
|
ELASTICSEARCH_USERNAME: elastic
|
|
|
|
|
ELASTICSEARCH_PASSWORD: ${ELASTIC_PASSWORD}
|
|
|
|
|
# Remove the published port: Kibana is reached through a tunnel or proxy.
|
|
|
|
|
ports: !reset []
|
|
|
|
|
deploy:
|
|
|
|
|
resources:
|
|
|
|
|
limits: { memory: 1g }
|
|
|
|
|
|
|
|
|
|
backend:
|
|
|
|
|
environment:
|
|
|
|
|
DJANGO_SETTINGS_MODULE: config.settings.prod
|
|
|
|
|
DJANGO_DEBUG: "false"
|
|
|
|
|
SECURE_SSL_REDIRECT: "true"
|
2026-09-04 12:52:21 +03:30
|
|
|
# Off unless deliberately opted in: /api/docs/ and /api/schema/ publish
|
|
|
|
|
# the entire API surface to anonymous callers.
|
|
|
|
|
SERVE_API_DOCS: ${SERVE_API_DOCS:-false}
|
2026-09-03 08:13:10 +00:00
|
|
|
ELASTICSEARCH_USERNAME: elastic
|
|
|
|
|
ELASTICSEARCH_PASSWORD: ${ELASTIC_PASSWORD}
|
|
|
|
|
# A production container must never mutate data on boot.
|
|
|
|
|
SEED_DATASET: "false"
|
|
|
|
|
SEED_USERS: "false"
|
2026-09-04 12:52:21 +03:30
|
|
|
# Nor schema-migrate on boot: with `replicas: 2` below, both containers
|
|
|
|
|
# would start `manage.py migrate` at the same moment and race each other
|
|
|
|
|
# through the same DDL. Migrations are a deliberate, one-at-a-time
|
|
|
|
|
# deploy step instead - `make prod-migrate`, which runs the one-shot
|
|
|
|
|
# `backend-migrate` service defined below.
|
|
|
|
|
RUN_MIGRATIONS: "false"
|
2026-09-03 08:13:10 +00:00
|
|
|
deploy:
|
|
|
|
|
replicas: 2
|
|
|
|
|
resources:
|
|
|
|
|
limits: { cpus: "1.0", memory: 768m }
|
|
|
|
|
|
2026-09-04 12:52:21 +03:30
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# One-shot schema migration, run by `make prod-migrate` before rolling out a
|
|
|
|
|
# new image. Kept out of the default set by the "migrate" profile so a plain
|
|
|
|
|
# `up` never starts it.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
backend-migrate:
|
|
|
|
|
extends:
|
|
|
|
|
file: docker-compose.yml
|
|
|
|
|
service: backend
|
|
|
|
|
profiles: ["migrate"]
|
|
|
|
|
restart: "no"
|
|
|
|
|
entrypoint: []
|
|
|
|
|
command: ["python", "manage.py", "migrate", "--noinput"]
|
|
|
|
|
environment:
|
|
|
|
|
DJANGO_SETTINGS_MODULE: config.settings.prod
|
|
|
|
|
DJANGO_DEBUG: "false"
|
|
|
|
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:?DJANGO_SECRET_KEY is required}
|
|
|
|
|
DATABASE_URL: postgres://${POSTGRES_USER:-linkedin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-linkedin}
|
|
|
|
|
# Migrating must not depend on the search cluster being reachable.
|
|
|
|
|
SEARCH_BACKEND: postgres
|
|
|
|
|
SECURE_SSL_REDIRECT: "false"
|
|
|
|
|
deploy: !reset null
|
|
|
|
|
|
2026-09-03 08:13:10 +00:00
|
|
|
frontend:
|
|
|
|
|
deploy:
|
|
|
|
|
resources:
|
|
|
|
|
limits: { cpus: "0.5", memory: 128m }
|
|
|
|
|
|
|
|
|
|
postgres:
|
|
|
|
|
deploy:
|
|
|
|
|
resources:
|
|
|
|
|
limits: { memory: 1g }
|
2026-09-04 12:52:21 +03:30
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
# Data durability
|
|
|
|
|
#
|
|
|
|
|
# The base compose file declares these as ordinary project volumes, which
|
|
|
|
|
# `docker compose down -v` (and therefore `make clean`) deletes without
|
|
|
|
|
# asking. Marking them external in production flips that guarantee: Compose
|
|
|
|
|
# treats them as owned by the operator, never creates them and - crucially -
|
|
|
|
|
# never removes them, so a mistyped teardown cannot destroy the database or
|
|
|
|
|
# the search index.
|
|
|
|
|
#
|
|
|
|
|
# They must exist before the first `make prod`; `make prod-volumes` creates
|
|
|
|
|
# them. Back them up with your usual volume/snapshot tooling - external only
|
|
|
|
|
# means Compose will not delete them, not that they are backed up.
|
|
|
|
|
# =============================================================================
|
|
|
|
|
volumes:
|
|
|
|
|
postgres-data:
|
|
|
|
|
external: true
|
|
|
|
|
name: ${DATA_VOLUME_PREFIX:-linkedin-search}-postgres-data
|
|
|
|
|
elasticsearch-data:
|
|
|
|
|
external: true
|
|
|
|
|
name: ${DATA_VOLUME_PREFIX:-linkedin-search}-elasticsearch-data
|
|
|
|
|
logstash-data:
|
|
|
|
|
external: true
|
|
|
|
|
name: ${DATA_VOLUME_PREFIX:-linkedin-search}-logstash-data
|