# ============================================================================= # 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:-} # 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. 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 # Explicit: the Dockerfile has a later `dev` stage that must not leak # into the default stack. target: runtime <<: *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} # 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} 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] # --------------------------------------------------------------------------- # 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_` 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] 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 # 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. tmpfs: - /tmp:mode=1777 - /var/run:mode=1777 - /var/cache/nginx:mode=1777 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: