2026-09-03 08:13:10 +00:00
|
|
|
# =============================================================================
|
|
|
|
|
# Full stack: PostgreSQL + Redis + Elasticsearch + Logstash + Kibana + API + SPA
|
|
|
|
|
#
|
|
|
|
|
# cp .env.example .env && docker compose up --build
|
|
|
|
|
# → SPA http://localhost:8080
|
|
|
|
|
# → API docs http://localhost:8080/api/docs/
|
|
|
|
|
# → Kibana http://localhost:5601
|
|
|
|
|
#
|
|
|
|
|
# Only the SPA and Kibana are published to the host; the database, cache and
|
|
|
|
|
# the whole ELK stack stay on private Docker networks.
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
name: linkedin-search
|
|
|
|
|
|
|
|
|
|
x-service-defaults: &service-defaults
|
|
|
|
|
restart: unless-stopped
|
|
|
|
|
logging:
|
|
|
|
|
driver: json-file
|
|
|
|
|
options:
|
|
|
|
|
max-size: "10m"
|
|
|
|
|
max-file: "3"
|
|
|
|
|
|
|
|
|
|
services:
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Data stores
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
postgres:
|
|
|
|
|
image: postgres:16-alpine
|
|
|
|
|
<<: *service-defaults
|
|
|
|
|
environment:
|
|
|
|
|
POSTGRES_DB: ${POSTGRES_DB:-linkedin}
|
|
|
|
|
POSTGRES_USER: ${POSTGRES_USER:-linkedin}
|
|
|
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
|
|
|
|
|
# Slightly stricter than the default md5 authentication.
|
|
|
|
|
POSTGRES_INITDB_ARGS: "--auth-host=scram-sha-256"
|
|
|
|
|
volumes:
|
|
|
|
|
- postgres-data:/var/lib/postgresql/data
|
|
|
|
|
healthcheck:
|
|
|
|
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-linkedin} -d ${POSTGRES_DB:-linkedin}"]
|
|
|
|
|
interval: 10s
|
|
|
|
|
timeout: 5s
|
|
|
|
|
retries: 10
|
|
|
|
|
start_period: 20s
|
|
|
|
|
networks: [backend-net]
|
|
|
|
|
|
|
|
|
|
redis:
|
|
|
|
|
image: redis:7-alpine
|
|
|
|
|
<<: *service-defaults
|
|
|
|
|
command: ["redis-server", "--save", "", "--appendonly", "no", "--maxmemory", "128mb", "--maxmemory-policy", "allkeys-lru"]
|
|
|
|
|
healthcheck:
|
|
|
|
|
test: ["CMD", "redis-cli", "ping"]
|
|
|
|
|
interval: 10s
|
|
|
|
|
timeout: 3s
|
|
|
|
|
retries: 5
|
|
|
|
|
networks: [backend-net]
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# ELK - search engine and log pipeline
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
elasticsearch:
|
|
|
|
|
image: docker.elastic.co/elasticsearch/elasticsearch:8.15.3
|
|
|
|
|
<<: *service-defaults
|
|
|
|
|
environment:
|
|
|
|
|
node.name: elasticsearch
|
|
|
|
|
discovery.type: single-node
|
|
|
|
|
bootstrap.memory_lock: "true"
|
|
|
|
|
ES_JAVA_OPTS: ${ES_JAVA_OPTS:--Xms512m -Xmx512m}
|
|
|
|
|
# Development defaults: no TLS, no authentication, and the cluster is not
|
|
|
|
|
# reachable from outside the Docker network. docker-compose.prod.yml
|
|
|
|
|
# turns authentication on - see docs/SECURITY.md.
|
|
|
|
|
xpack.security.enabled: "false"
|
|
|
|
|
xpack.security.enrollment.enabled: "false"
|
|
|
|
|
xpack.ml.enabled: "false"
|
|
|
|
|
ingest.geoip.downloader.enabled: "false"
|
|
|
|
|
ulimits:
|
|
|
|
|
memlock: { soft: -1, hard: -1 }
|
|
|
|
|
nofile: { soft: 65536, hard: 65536 }
|
|
|
|
|
volumes:
|
|
|
|
|
- elasticsearch-data:/usr/share/elasticsearch/data
|
|
|
|
|
healthcheck:
|
|
|
|
|
test: ["CMD-SHELL", "curl -fsS 'http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=5s' >/dev/null || exit 1"]
|
|
|
|
|
interval: 15s
|
|
|
|
|
timeout: 10s
|
|
|
|
|
retries: 12
|
|
|
|
|
start_period: 60s
|
|
|
|
|
networks: [backend-net, elk-net]
|
|
|
|
|
|
|
|
|
|
logstash:
|
|
|
|
|
image: docker.elastic.co/logstash/logstash:8.15.3
|
|
|
|
|
<<: *service-defaults
|
|
|
|
|
environment:
|
|
|
|
|
LS_JAVA_OPTS: ${LS_JAVA_OPTS:--Xms256m -Xmx256m}
|
|
|
|
|
ELASTICSEARCH_HOSTS: http://elasticsearch:9200
|
|
|
|
|
ELASTICSEARCH_USERNAME: ${ELASTICSEARCH_USERNAME:-}
|
|
|
|
|
ELASTICSEARCH_PASSWORD: ${ELASTICSEARCH_PASSWORD:-}
|
2026-09-04 12:52:21 +03:30
|
|
|
# No `xpack.monitoring.enabled` here on purpose: any logstash.* variable
|
|
|
|
|
# makes the image entrypoint rewrite config/logstash.yml, which is
|
|
|
|
|
# mounted read-only below. The setting already lives in that file.
|
2026-09-03 08:13:10 +00:00
|
|
|
volumes:
|
|
|
|
|
- ./elk/logstash/pipeline:/usr/share/logstash/pipeline:ro
|
|
|
|
|
- ./elk/logstash/templates:/usr/share/logstash/templates:ro
|
|
|
|
|
- ./elk/logstash/config/logstash.yml:/usr/share/logstash/config/logstash.yml:ro
|
|
|
|
|
- logstash-data:/usr/share/logstash/data
|
|
|
|
|
healthcheck:
|
|
|
|
|
test: ["CMD-SHELL", "curl -fsS http://localhost:9600/_node/pipelines || exit 1"]
|
|
|
|
|
interval: 20s
|
|
|
|
|
timeout: 10s
|
|
|
|
|
retries: 10
|
|
|
|
|
start_period: 60s
|
|
|
|
|
depends_on:
|
|
|
|
|
elasticsearch:
|
|
|
|
|
condition: service_healthy
|
|
|
|
|
networks: [backend-net, elk-net]
|
|
|
|
|
|
|
|
|
|
kibana:
|
|
|
|
|
image: docker.elastic.co/kibana/kibana:8.15.3
|
|
|
|
|
<<: *service-defaults
|
|
|
|
|
environment:
|
|
|
|
|
SERVER_NAME: kibana
|
|
|
|
|
SERVER_PUBLICBASEURL: http://localhost:${KIBANA_PORT:-5601}
|
|
|
|
|
ELASTICSEARCH_HOSTS: '["http://elasticsearch:9200"]'
|
|
|
|
|
# A stable key keeps saved objects readable across restarts.
|
|
|
|
|
XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY: ${KIBANA_ENCRYPTION_KEY:-a7f3c1d9e5b28046a7f3c1d9e5b28046}
|
|
|
|
|
TELEMETRY_ENABLED: "false"
|
|
|
|
|
ports:
|
|
|
|
|
# Bound to the loopback interface: the dashboard is a local tool, not a
|
|
|
|
|
# public endpoint.
|
|
|
|
|
- "127.0.0.1:${KIBANA_PORT:-5601}:5601"
|
|
|
|
|
healthcheck:
|
|
|
|
|
test: ["CMD-SHELL", "curl -fsS http://localhost:5601/api/status | grep -q '\"level\":\"available\"' || exit 1"]
|
|
|
|
|
interval: 20s
|
|
|
|
|
timeout: 10s
|
|
|
|
|
retries: 15
|
|
|
|
|
start_period: 90s
|
|
|
|
|
depends_on:
|
|
|
|
|
elasticsearch:
|
|
|
|
|
condition: service_healthy
|
|
|
|
|
networks: [elk-net]
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Application
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
backend:
|
|
|
|
|
build:
|
|
|
|
|
context: .
|
|
|
|
|
dockerfile: backend/Dockerfile
|
2026-09-04 12:52:21 +03:30
|
|
|
# Explicit: the Dockerfile has a later `dev` stage that must not leak
|
|
|
|
|
# into the default stack.
|
|
|
|
|
target: runtime
|
2026-09-03 08:13:10 +00:00
|
|
|
<<: *service-defaults
|
|
|
|
|
environment:
|
|
|
|
|
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-config.settings.prod}
|
|
|
|
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:?DJANGO_SECRET_KEY is required}
|
|
|
|
|
DJANGO_DEBUG: ${DJANGO_DEBUG:-false}
|
|
|
|
|
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:-localhost,127.0.0.1,backend}
|
|
|
|
|
DJANGO_ADMIN_URL: ${DJANGO_ADMIN_URL:-admin/}
|
|
|
|
|
SECURE_SSL_REDIRECT: ${SECURE_SSL_REDIRECT:-false}
|
2026-09-04 12:52:21 +03:30
|
|
|
# This stack is the local/demo one, so the interactive API docs are on.
|
|
|
|
|
# The production overlay turns them off - they document every endpoint
|
|
|
|
|
# and are served unauthenticated.
|
|
|
|
|
SERVE_API_DOCS: ${SERVE_API_DOCS:-true}
|
2026-09-03 08:13:10 +00:00
|
|
|
ENVIRONMENT: ${ENVIRONMENT:-development}
|
|
|
|
|
SERVICE_NAME: ${SERVICE_NAME:-linkedin-search-api}
|
|
|
|
|
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS:-http://localhost:8080}
|
|
|
|
|
CSRF_TRUSTED_ORIGINS: ${CSRF_TRUSTED_ORIGINS:-http://localhost:8080}
|
|
|
|
|
DATABASE_URL: postgres://${POSTGRES_USER:-linkedin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-linkedin}
|
|
|
|
|
POSTGRES_HOST: postgres
|
|
|
|
|
POSTGRES_PORT: "5432"
|
|
|
|
|
REDIS_URL: ${REDIS_URL:-redis://redis:6379/1}
|
|
|
|
|
SEARCH_BACKEND: ${SEARCH_BACKEND:-elasticsearch}
|
|
|
|
|
ELASTICSEARCH_HOST: elasticsearch
|
|
|
|
|
ELASTICSEARCH_PORT: "9200"
|
|
|
|
|
ELASTICSEARCH_HOSTS: ${ELASTICSEARCH_HOSTS:-http://elasticsearch:9200}
|
|
|
|
|
ELASTICSEARCH_INDEX: ${ELASTICSEARCH_INDEX:-linkedin-profiles}
|
|
|
|
|
ELASTICSEARCH_USERNAME: ${ELASTICSEARCH_USERNAME:-}
|
|
|
|
|
ELASTICSEARCH_PASSWORD: ${ELASTICSEARCH_PASSWORD:-}
|
|
|
|
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
|
|
|
|
LOGSTASH_ENABLED: ${LOGSTASH_ENABLED:-true}
|
|
|
|
|
LOGSTASH_HOST: logstash
|
|
|
|
|
LOGSTASH_PORT: "5959"
|
|
|
|
|
THROTTLE_ANON: ${THROTTLE_ANON:-30/min}
|
|
|
|
|
THROTTLE_USER: ${THROTTLE_USER:-300/min}
|
|
|
|
|
THROTTLE_SEARCH: ${THROTTLE_SEARCH:-120/min}
|
|
|
|
|
THROTTLE_LOGIN: ${THROTTLE_LOGIN:-10/min}
|
|
|
|
|
JWT_ACCESS_MINUTES: ${JWT_ACCESS_MINUTES:-15}
|
|
|
|
|
JWT_REFRESH_DAYS: ${JWT_REFRESH_DAYS:-7}
|
|
|
|
|
DATASET_PATH: ${DATASET_PATH:-/data/linkedin_profiles.csv}
|
|
|
|
|
RUN_MIGRATIONS: ${RUN_MIGRATIONS:-true}
|
|
|
|
|
SEED_DATASET: ${SEED_DATASET:-true}
|
|
|
|
|
SEED_USERS: ${SEED_USERS:-true}
|
|
|
|
|
GUNICORN_WORKERS: ${GUNICORN_WORKERS:-3}
|
|
|
|
|
DEMO_ADMIN_EMAIL: ${DEMO_ADMIN_EMAIL:-}
|
|
|
|
|
DEMO_ADMIN_PASSWORD: ${DEMO_ADMIN_PASSWORD:-}
|
|
|
|
|
DEMO_ANALYST_EMAIL: ${DEMO_ANALYST_EMAIL:-}
|
|
|
|
|
DEMO_ANALYST_PASSWORD: ${DEMO_ANALYST_PASSWORD:-}
|
|
|
|
|
DEMO_VIEWER_EMAIL: ${DEMO_VIEWER_EMAIL:-}
|
|
|
|
|
DEMO_VIEWER_PASSWORD: ${DEMO_VIEWER_PASSWORD:-}
|
|
|
|
|
depends_on:
|
|
|
|
|
postgres:
|
|
|
|
|
condition: service_healthy
|
|
|
|
|
redis:
|
|
|
|
|
condition: service_healthy
|
|
|
|
|
elasticsearch:
|
|
|
|
|
condition: service_healthy
|
|
|
|
|
security_opt:
|
|
|
|
|
- no-new-privileges:true
|
|
|
|
|
networks: [backend-net, frontend-net]
|
|
|
|
|
|
2026-09-04 12:52:21 +03:30
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Test runner - never started by `up`/`dev`/`prod` (the "test" profile keeps
|
|
|
|
|
# it out of the default set); invoked only via `make test`, which runs
|
|
|
|
|
# `docker compose run --rm backend-test`.
|
|
|
|
|
#
|
|
|
|
|
# The `backend` service above intentionally runs the `runtime` build stage,
|
|
|
|
|
# which ships no test tooling - that is what keeps the production image
|
|
|
|
|
# minimal. `pytest`, `django-extensions` etc. live only in the `dev` stage,
|
|
|
|
|
# so the test runner builds that stage instead. It talks to the same
|
|
|
|
|
# Postgres the rest of the stack uses; pytest-django creates and migrates
|
|
|
|
|
# its own throwaway `test_<db>` database there, and `config.settings.test`
|
|
|
|
|
# forces the search backend to Postgres, so Elasticsearch/Redis are not
|
|
|
|
|
# dependencies here.
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
backend-test:
|
|
|
|
|
build:
|
|
|
|
|
context: .
|
|
|
|
|
dockerfile: backend/Dockerfile
|
|
|
|
|
target: dev
|
|
|
|
|
profiles: ["test"]
|
|
|
|
|
entrypoint: []
|
|
|
|
|
command: ["pytest"]
|
|
|
|
|
environment:
|
|
|
|
|
DJANGO_SETTINGS_MODULE: config.settings.test
|
|
|
|
|
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:?DJANGO_SECRET_KEY is required}
|
|
|
|
|
DATABASE_URL: postgres://${POSTGRES_USER:-linkedin}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-linkedin}
|
|
|
|
|
depends_on:
|
|
|
|
|
postgres:
|
|
|
|
|
condition: service_healthy
|
|
|
|
|
networks: [backend-net]
|
|
|
|
|
|
2026-09-03 08:13:10 +00:00
|
|
|
frontend:
|
|
|
|
|
build:
|
|
|
|
|
context: .
|
|
|
|
|
dockerfile: frontend/Dockerfile
|
|
|
|
|
<<: *service-defaults
|
|
|
|
|
ports:
|
|
|
|
|
- "${FRONTEND_PORT:-8080}:8080"
|
|
|
|
|
depends_on:
|
|
|
|
|
- backend
|
|
|
|
|
security_opt:
|
|
|
|
|
- no-new-privileges:true
|
|
|
|
|
read_only: true
|
2026-09-04 12:52:21 +03:30
|
|
|
# A tmpfs mount is created root:root 0755 and shadows whatever the image
|
|
|
|
|
# chowned at build time, so nginx (uid 101) needs the mode set explicitly.
|
2026-09-03 08:13:10 +00:00
|
|
|
tmpfs:
|
2026-09-04 12:52:21 +03:30
|
|
|
- /tmp:mode=1777
|
|
|
|
|
- /var/run:mode=1777
|
|
|
|
|
- /var/cache/nginx:mode=1777
|
2026-09-03 08:13:10 +00:00
|
|
|
networks: [frontend-net]
|
|
|
|
|
|
|
|
|
|
networks:
|
|
|
|
|
# The SPA container may talk to the API and nothing else.
|
|
|
|
|
frontend-net:
|
|
|
|
|
driver: bridge
|
|
|
|
|
# Databases and the search cluster - no route to the host.
|
|
|
|
|
backend-net:
|
|
|
|
|
driver: bridge
|
|
|
|
|
internal: true
|
|
|
|
|
elk-net:
|
|
|
|
|
driver: bridge
|
|
|
|
|
|
|
|
|
|
volumes:
|
|
|
|
|
postgres-data:
|
|
|
|
|
elasticsearch-data:
|
|
|
|
|
logstash-data:
|