diff --git a/.dockerignore b/.dockerignore index 0cde3ab87..1d84df180 100644 --- a/.dockerignore +++ b/.dockerignore @@ -70,12 +70,20 @@ ENV/ node_modules/ # Git -.git/ +.git .gitignore - -# Docker -Dockerfile* +README.md +Dockerfile .dockerignore - -# Migrations cache -**/migrations/__pycache__/ +docker-compose.yml +.vscode +.idea +*.pyc +__pycache__ +.pytest_cache +.coverage +*.log +.env +horillavenv +node_modules +.DS_Store diff --git a/Dockerfile b/Dockerfile index a28d89f62..640164e1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,77 +1,49 @@ -# syntax=docker/dockerfile:1 +FROM python:3.12-slim -# -------- Base image with dependencies layer -------- -FROM python:3.11-slim AS base - -# System deps ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 \ - POETRY_VIRTUALENVS_CREATE=false + PYTHONUNBUFFERED=1 -# Install build deps for common Python packages (incl. cairo) -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - libpq-dev \ - gcc \ - curl \ - pkg-config \ - libcairo2-dev \ - libpango1.0-dev \ - libjpeg62-turbo-dev \ - zlib1g-dev \ - libxml2-dev \ - libxslt1-dev \ +# Install system dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + libpq-dev \ + libjpeg-dev \ + zlib1g-dev \ + libcairo2-dev \ + libpango1.0-dev \ + libgdk-pixbuf-xlib-2.0-dev \ + libxml2-dev \ + libxslt1-dev \ + libffi-dev \ + pkg-config \ + curl \ + netcat-openbsd \ && rm -rf /var/lib/apt/lists/* -WORKDIR /app - -# -------- Builder for wheels -------- -FROM base AS builder -COPY requirements.txt ./ -RUN pip wheel --wheel-dir /wheels -r requirements.txt - -# -------- Final runtime image -------- -FROM python:3.11-slim AS runtime - # Create non-root user -RUN addgroup --system app && adduser --system --ingroup app app - -# Install runtime deps for libraries like psycopg2, cairo, etc. (minimal) -RUN apt-get update && apt-get install -y --no-install-recommends \ - libpq5 \ - libcairo2 \ - libpango-1.0-0 \ - libjpeg62-turbo \ - zlib1g \ - libxml2 \ - libxslt1.1 \ - libffi8 \ - libfreetype6 \ - ghostscript \ - && rm -rf /var/lib/apt/lists/* +RUN useradd --create-home --uid 1000 appuser WORKDIR /app -# Copy wheels and install -COPY --from=builder /wheels /wheels -RUN pip install --no-index --find-links=/wheels /wheels/* +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt gunicorn psycopg2-binary -# Copy project +# Copy application COPY . . -# Ensure static dirs exist and are owned by app -RUN mkdir -p /app/staticfiles /app/static_root && chown -R app:app /app +# Set permissions +RUN mkdir -p staticfiles media \ + && chown -R appuser:appuser /app -# Gunicorn config -ENV PORT=8000 \ - GUNICORN_CMD_ARGS="--config deploy/gunicorn.conf.py" - -# Entrypoint -COPY deploy/entrypoint.sh /entrypoint.sh +# Copy entrypoint +COPY docker/entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh -USER app +USER appuser + EXPOSE 8000 -CMD ["/entrypoint.sh"] +ENTRYPOINT ["/entrypoint.sh"] +CMD ["gunicorn", "horilla.wsgi:application", "--config", "docker/gunicorn.conf.py"] diff --git a/deploy/entrypoint.sh b/deploy/entrypoint.sh deleted file mode 100644 index bf897f47e..000000000 --- a/deploy/entrypoint.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/sh -set -euo pipefail - -# Default envs -: "${DJANGO_SETTINGS_MODULE:=horilla.settings}" -: "${PORT:=8000}" -: "${DATABASE_URL:=}" - -python manage.py migrate --noinput -python manage.py collectstatic --noinput - -exec gunicorn horilla.wsgi:application diff --git a/deploy/gunicorn.conf.py b/deploy/gunicorn.conf.py deleted file mode 100644 index e75886ac8..000000000 --- a/deploy/gunicorn.conf.py +++ /dev/null @@ -1,38 +0,0 @@ -import multiprocessing -import os - -# Server socket -bind = "0.0.0.0:8000" -backlog = 2048 - -# Worker processes -workers = max(2, min(multiprocessing.cpu_count() * 2 + 1, 8)) -worker_class = "gthread" -threads = 4 -worker_connections = 1000 -max_requests = 1000 -max_requests_jitter = 50 -preload_app = True - -# Timeout -timeout = 120 -keepalive = 5 - -# Logging -accesslog = "-" -errorlog = "-" -loglevel = os.getenv("LOG_LEVEL", "info") -access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s' - -# Process naming -proc_name = "horilla-hrms" - -# Server mechanics -pidfile = "/tmp/gunicorn.pid" -user = None # Run as current user in container -group = None -tmp_upload_dir = None - -# SSL (if needed) -# keyfile = "/path/to/keyfile" -# certfile = "/path/to/certfile" diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf deleted file mode 100644 index e27b6601f..000000000 --- a/deploy/nginx/nginx.conf +++ /dev/null @@ -1,118 +0,0 @@ -user nginx; -worker_processes auto; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; - use epoll; - multi_accept on; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - types_hash_max_size 2048; - client_max_body_size 50M; - - # Gzip compression - gzip on; - gzip_vary on; - gzip_min_length 10240; - gzip_proxied any; - gzip_types - text/plain - text/css - text/xml - text/javascript - application/x-javascript - application/xml+rss - application/javascript - application/json; - - server_tokens off; - - # Rate limiting - limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s; - limit_req_zone $binary_remote_addr zone=login:10m rate=5r/m; - - # Upstream for load balancing (if needed) - upstream app { - server web:8000 max_fails=3 fail_timeout=30s; - } - - server { - listen 80; - server_name _; - - # Security headers - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - - # Serve static files directly - location /static/ { - alias /staticfiles/; - expires 1y; - add_header Cache-Control "public, immutable"; - access_log off; - } - - # Serve media files - location /media/ { - alias /media/; - expires 30d; - add_header Cache-Control "public"; - access_log off; - } - - # API rate limiting - location /api/ { - limit_req zone=api burst=20 nodelay; - proxy_pass http://app; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 300s; - proxy_connect_timeout 300s; - } - - # Login rate limiting - location ~ ^/(login|auth)/ { - limit_req zone=login burst=5 nodelay; - proxy_pass http://app; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 300s; - proxy_connect_timeout 300s; - } - - # Main application - location / { - proxy_pass http://app; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 300s; - proxy_connect_timeout 300s; - proxy_redirect off; - } - } -} diff --git a/docker-compose.yml b/docker-compose.yml index d1f3f1982..f60d10941 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,53 +1,56 @@ -version: "3.9" - services: + web: + build: . + ports: + - "8000:8000" + volumes: + - .:/app + - staticfiles:/app/staticfiles + - media:/app/media + environment: + - DEBUG=1 + - SECRET_KEY=dev-secret-key + - ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0 + - CSRF_TRUSTED_ORIGINS=http://localhost:8000 + - DATABASE_URL=postgres://horilla_user:horilla_pass@db:5432/horilla_db + - REDIS_URL=redis://:horilla_pass@redis:6379/0 + depends_on: + - db + - redis + db: image: postgres:16-alpine environment: - POSTGRES_DB: ${POSTGRES_DB:-horilla} - POSTGRES_USER: ${POSTGRES_USER:-horilla} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-horilla} + POSTGRES_DB: horilla_db + POSTGRES_USER: horilla_user + POSTGRES_PASSWORD: horilla_pass volumes: - - db_data:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"] - interval: 5s - timeout: 5s - retries: 5 + - postgres_data:/var/lib/postgresql/data + ports: + - "5432:5432" redis: image: redis:7-alpine - command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-horilla} + command: redis-server --appendonly yes --requirepass horilla_pass volumes: - redis_data:/data - healthcheck: - test: ["CMD", "redis-cli", "--raw", "incr", "ping"] - interval: 5s - timeout: 3s - retries: 5 - - web: - build: - context: . - command: /entrypoint.sh - environment: - DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-horilla.settings} - SECRET_KEY: ${SECRET_KEY:-change-me} - DEBUG: ${DEBUG:-False} - ALLOWED_HOSTS: ${ALLOWED_HOSTS:-*} - DATABASE_URL: postgresql://${POSTGRES_USER:-horilla}:${POSTGRES_PASSWORD:-horilla}@db:5432/${POSTGRES_DB:-horilla} - REDIS_URL: redis://:${REDIS_PASSWORD:-horilla}@redis:6379/0 ports: - - "8000:8000" - depends_on: - db: - condition: service_healthy - redis: - condition: service_healthy + - "6379:6379" + + nginx: + image: nginx:alpine + ports: + - "80:80" volumes: - - static_data:/app/staticfiles + - staticfiles:/static:ro + - media:/media:ro + - ./docker/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - web + profiles: ["production"] volumes: - db_data: + staticfiles: + media: + postgres_data: redis_data: - static_data: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 000000000..e9066a9c7 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +echo "Starting Horilla HR..." + +# Wait for PostgreSQL to be ready +echo "Waiting for PostgreSQL..." +while ! nc -z db 5432; do + sleep 0.1 +done +echo "PostgreSQL is ready!" + +# Run migrations +python manage.py migrate --noinput + +# Collect static files +python manage.py collectstatic --noinput + +echo "Starting server..." +exec "$@" \ No newline at end of file diff --git a/docker/gunicorn.conf.py b/docker/gunicorn.conf.py new file mode 100644 index 000000000..c3a7739e1 --- /dev/null +++ b/docker/gunicorn.conf.py @@ -0,0 +1,45 @@ +# Gunicorn configuration for Horilla-HR +# This file provides advanced configuration options for the WSGI server + +import os +import multiprocessing + +# Bind settings +bind = f"0.0.0.0:{os.environ.get('PORT', '8000')}" +host = "0.0.0.0" +port = int(os.environ.get('PORT', '8000')) + +# Worker settings +workers = int(os.environ.get('GUNICORN_WORKERS', max(2, min(multiprocessing.cpu_count() * 2 + 1, 8)))) +worker_class = "gthread" +threads = 4 +worker_connections = 1000 +max_requests = 1000 +max_requests_jitter = 50 +preload_app = True + +# Timeout settings +timeout = 120 +keepalive = 5 + +# Logging +accesslog = "-" +errorlog = "-" +loglevel = os.environ.get('GUNICORN_LOG_LEVEL', 'info') +access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s' + +# Process naming +proc_name = "horilla-hrms" + +# Server mechanics +pidfile = "/tmp/gunicorn.pid" +user = None # Run as current user in container +group = None +tmp_upload_dir = None + +# Development settings +reload = os.environ.get('GUNICORN_RELOAD', 'false').lower() == 'true' + +# SSL settings (if needed) +# ssl_keyfile = os.environ.get('SSL_KEYFILE') +# ssl_certfile = os.environ.get('SSL_CERTFILE') \ No newline at end of file diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 000000000..d73754a6b --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,31 @@ +events { + worker_connections 1024; +} + +http { + upstream django { + server web:8000; + } + + server { + listen 80; + client_max_body_size 50M; + + location /static/ { + alias /static/; + expires 1y; + } + + location /media/ { + alias /media/; + } + + location / { + proxy_pass http://django; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + } +} \ No newline at end of file