Files
cyberyan_linkedin_search/docker-compose.yml
T
mobiniqow a464e550c9 feat: LinkedIn profile search engine (Django + React + Elasticsearch + ELK)
Full-stack implementation of the Cyberyan full-stack trial task.

Backend (Django 5 / DRF)
- Normalised PostgreSQL model (Profile, Company, School, Skill, Experience,
  Education) as the source of truth, with GIN + trigram indexes.
- Self-healing CSV reader: the supplied export carries an injected watermark
  column, missing columns, rows split by broken quoting and a duplicated
  header. A signature-scored Needleman-Wunsch alignment reconstructs 284 clean
  records out of 336 physical rows; only 10 rows parse correctly without it.
- Pluggable search layer (Strategy): Elasticsearch by default, PostgreSQL
  full-text search as a fallback selected by a single environment variable.
- Keyword search with per-field boosts, fuzziness and phrase boosting, six
  faceted filters (OR within a filter, AND across filters), an experience
  range, four sort orders, highlighting and facet aggregations.
- JWT auth with rotation + blacklist, three roles, PII masking, scoped rate
  limits, a single error envelope, OpenAPI schema and health probes.

Frontend (React 18 / TypeScript / Vite)
- Debounced search with type-ahead, faceted filter panel, result cards,
  profile drawer, URL-synced search state, automatic token refresh.
- Highlight snippets rendered without dangerouslySetInnerHTML.

Infrastructure
- Hardened multi-stage images (non-root, read-only rootfs, healthchecks).
- docker-compose for the full stack plus dev and production overlays.
- ELK: structured JSON logs shipped asynchronously to Logstash, routed per
  event type, with a composable index template and a Kibana setup script.

Quality
- 61 backend tests (85% coverage), ruff/black/bandit/mypy, ESLint + tsc,
  GitHub Actions running lint, tests and both image builds.
- Persian documentation: README plus architecture, search, API, data,
  security and operations guides.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GnFo6fNaHCTAkJbfcdkqtG
2026-09-03 08:13:50 +00:00

233 lines
8.3 KiB
YAML

# =============================================================================
# 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:-}
xpack.monitoring.enabled: "false"
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
<<: *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}
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]
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
tmpfs:
- /tmp
- /var/run
- /var/cache/nginx
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: