Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# VCS
.git
.github
.gitignore

# Python artifacts
__pycache__
*.py[oc]
*.egg-info
build
dist

# Virtual environments and caches
.venv
.cache
.pytest_cache
.ruff_cache
.uv-cache

# Secrets and local data
.env
*.sqlite3
staticfiles

# Docker
Dockerfile
docker-compose*.yml
.dockerignore

# Tooling and docs
.pre-commit-config.yaml
Makefile
README.md

# IDE
.idea
.vscode
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ wheels/
# Logs and databases
*.log
*.sqlite3
staticfiles/
.cache/

# Virtual environments
Expand Down
93 changes: 55 additions & 38 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,64 +1,81 @@
ARG UID=1000
# syntax=docker/dockerfile:1

ARG PYTHON_VERSION=3.13

FROM ghcr.io/astral-sh/uv:0.11 AS uv


# ============================== builder ==============================
FROM python:${PYTHON_VERSION}-slim-trixie AS builder

RUN apt-get update && apt-get install -y \
build-essential \
libpq-dev \
curl \
&& rm -rf /var/lib/apt/lists/*
ENV UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_PYTHON_DOWNLOADS=never \
UV_PROJECT_ENVIRONMENT=/opt/venv

RUN useradd --create-home --shell /bin/bash builder
USER builder
WORKDIR /home/builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*

COPY --chown=builder:builder --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
COPY --from=uv /uv /uvx /usr/local/bin/

RUN uv venv .venv
ENV PATH="/home/builder/.venv/bin:$PATH"
WORKDIR /app

COPY --chown=builder:builder pyproject.toml uv.lock* ./
COPY pyproject.toml uv.lock ./

# --locked гарантирует точное соответствие uv.lock (сборка падает при рассинхроне)
ARG INSTALL_DEV=false
RUN if [ "$INSTALL_DEV" = "true" ] ; then \
uv sync --group dev ; \
RUN --mount=type=cache,target=/root/.cache/uv \
if [ "$INSTALL_DEV" = "true" ]; then \
uv sync --locked --group dev; \
else \
uv sync --no-group dev ; \
uv sync --locked --no-group dev; \
fi


# ============================== runtime ==============================
FROM python:${PYTHON_VERSION}-slim-trixie AS runtime

RUN apt-get update && apt-get install -y \
libpq5 \
netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*

ARG UID
RUN useradd --create-home --shell /bin/bash --uid ${UID:-1000} django
USER django
WORKDIR /home/django/app
LABEL org.opencontainers.image.title="django-boilerplate" \
org.opencontainers.image.description="Production-ready Django API boilerplate" \
org.opencontainers.image.source="https://github.com/ak4code/django_boilerplate"

COPY --from=builder --chown=django:django /home/builder/.venv /home/django/.venv

ENV VIRTUAL_ENV="/home/django/.venv"
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
ARG UID=1000
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
netcat-openbsd \
&& rm -rf /var/lib/apt/lists/* \
&& useradd --create-home --shell /usr/sbin/nologin --uid "${UID}" django \
&& install -d -o django -g django /app /app/staticfiles

ENV VIRTUAL_ENV=/opt/venv \
PATH=/opt/venv/bin:$PATH \
PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1

COPY --chown=django:django . .
# venv принадлежит root: приложение не может модифицировать свои зависимости
COPY --from=builder /opt/venv /opt/venv
COPY --chmod=755 entrypoint.sh /usr/local/bin/entrypoint.sh

RUN mkdir -p /home/django/logs /home/django/www
WORKDIR /app

ENV PYTHONPATH=/home/django/app \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
USER django

EXPOSE 8000

# Entrypoint
COPY --chown=django:django entrypoint.sh /home/django/entrypoint.sh
RUN chmod +x /home/django/entrypoint.sh
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health/', timeout=4)"]

ENTRYPOINT ["/home/django/entrypoint.sh"]
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

CMD ["dev"]


# ============================= production ============================
FROM runtime AS production

COPY --chown=django:django . .

CMD ["prod"]
11 changes: 10 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: env install run migrate makemigrations superuser test lint format up down logs
.PHONY: env install run migrate makemigrations superuser test lint format up down logs up-prod down-prod logs-prod

env:
@if [ -f .env ]; then \
Expand Down Expand Up @@ -42,3 +42,12 @@ down:

logs:
docker compose logs -f django

up-prod:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build

down-prod:
docker compose -f docker-compose.yml -f docker-compose.prod.yml down

logs-prod:
docker compose -f docker-compose.yml -f docker-compose.prod.yml logs -f django
38 changes: 34 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
| 👤 **Кастомный пользователь** | Email-логин + `external_id` (UUID) для интеграций |
| 💓 **Health-check** | `/health/` с проверкой БД и Redis — готов для k8s-проб |
| 🧹 **Качество кода** | ruff · bandit · ty · pre-commit · pytest · GitHub Actions |
| 🐳 **Docker** | Multi-stage образ (non-root) + compose с PostgreSQL и Redis |
| 🐳 **Docker** | Multi-stage образ (non-root, HEALTHCHECK) + compose dev/prod |

## 🚀 Быстрый старт

Expand Down Expand Up @@ -196,13 +196,43 @@ make up / down # 🐳 docker compose up/down
<br/>

`ENVIRONMENT=prod` включает: `DEBUG=False`, SSL redirect, HSTS, secure cookies,
JSON-only рендеринг DRF, манифестную статику (whitenoise) и запуск через gunicorn
(`MODE=prod` в docker-compose).
JSON-only рендеринг DRF, манифестную статику (whitenoise) и запуск через gunicorn.

```bash
ENVIRONMENT=prod MODE=prod INSTALL_DEV=false docker compose up -d --build
make up-prod
# или явно:
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
```

В prod-режиме код запекается в образ (таргет `production`, без bind-mount),
dev-зависимости не устанавливаются, порты PostgreSQL/Redis не публикуются,
на сервисы наложены лимиты CPU/памяти.

</details>

<details>
<summary><b>🐳 Устройство Docker-окружения</b></summary>

<br/>

**Dockerfile** (multi-stage):

| Стадия | Назначение |
| --- | --- |
| `builder` | Сборка venv через uv (`--locked`, BuildKit-кэш, прекомпиляция байткода) |
| `runtime` | Минимальный рантайм для разработки: non-root, venv принадлежит root |
| `production` | `runtime` + код, запечённый в образ |

Образ содержит `HEALTHCHECK`, который опрашивает `/health/` (БД + Redis).

**Compose** (три файла):

| Файл | Когда применяется |
| --- | --- |
| `docker-compose.yml` | Общая база: сервисы, healthchecks, лимиты логов |
| `docker-compose.override.yml` | Dev: подхватывается автоматически — bind-mount кода, порты БД на localhost |
| `docker-compose.prod.yml` | Prod: подключается явно — таргет `production`, лимиты ресурсов |

</details>

## 🧹 Качество кода
Expand Down
5 changes: 5 additions & 0 deletions config/settings/prod.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# Production settings
DEBUG = False

# Healthcheck Docker/k8s ходит изнутри контейнера на 127.0.0.1 по HTTP
if '127.0.0.1' not in ALLOWED_HOSTS: # noqa: F821
ALLOWED_HOSTS = [*ALLOWED_HOSTS, '127.0.0.1'] # noqa: F821
SECURE_REDIRECT_EXEMPT = [r'^health/$']

# Приложение работает за обратным прокси (nginx/traefik), который терминирует TLS
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SECURE_SSL_REDIRECT = env.bool('DJANGO_SECURE_SSL_REDIRECT', default=True) # noqa: F821
Expand Down
21 changes: 21 additions & 0 deletions docker-compose.override.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Dev-оверрайд: подхватывается `docker compose up` автоматически.
# Исходники монтируются в контейнер, порты БД доступны только с localhost.
services:
postgres:
ports:
- "127.0.0.1:5432:5432"

redis:
ports:
- "127.0.0.1:6379:6379"

django:
build:
target: runtime
args:
INSTALL_DEV: "true"
environment:
ENVIRONMENT: dev
volumes:
- .:/app
command: dev
32 changes: 32 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Production-оверрайд:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
# Код запекается в образ (без bind-mount), порты БД не публикуются.
services:
django:
build:
target: production
args:
INSTALL_DEV: "false"
environment:
ENVIRONMENT: prod
command: prod
deploy:
resources:
limits:
cpus: "${DJANGO_CPU_LIMIT:-2}"
memory: ${DJANGO_MEMORY_LIMIT:-1g}
reservations:
cpus: "0.5"
memory: 256m

postgres:
deploy:
resources:
limits:
memory: ${POSTGRES_MEMORY_LIMIT:-1g}

redis:
deploy:
resources:
limits:
memory: 384m
28 changes: 14 additions & 14 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
# Общая конфигурация. Для разработки автоматически подхватывается
# docker-compose.override.yml, для production:
# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"

services:
postgres:
image: postgres:18-alpine
hostname: postgres
ports:
- "5432:5432"
environment:
POSTGRES_DB: ${POSTGRES_DB:-app}
POSTGRES_USER: ${POSTGRES_USER:-app}
Expand All @@ -17,14 +24,13 @@ services:
retries: 5
start_period: 10s
restart: unless-stopped
logging: *default-logging
networks:
- backend

redis:
image: redis:8-alpine
hostname: redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
command: >
Expand All @@ -39,31 +45,26 @@ services:
retries: 5
start_period: 10s
restart: unless-stopped
logging: *default-logging
networks:
- backend

django:
build:
context: .
dockerfile: Dockerfile
target: runtime
args:
PYTHON_VERSION: ${PYTHON_VERSION:-3.13}
UID: ${UID:-1000}
INSTALL_DEV: ${INSTALL_DEV:-true}
hostname: django
init: true
ports:
- "8000:8000"
volumes:
- .:/home/django/app
- ./.cache/logs:/home/django/logs
- ./.cache/www:/home/django/www
- uv_cache:/home/django/.cache/uv
environment:
ENVIRONMENT: ${ENVIRONMENT:-dev}
DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-config.settings}
DATABASE_URL: postgres://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-app}@postgres:5432/${POSTGRES_DB:-app}
REDIS_URL: redis://redis:6379/1
REDIS_HOST: redis
POSTGRES_HOST: postgres
env_file:
- ./.env
Expand All @@ -72,15 +73,14 @@ services:
condition: service_healthy
redis:
condition: service_healthy
command: ${MODE:-dev}
restart: unless-stopped
logging: *default-logging
networks:
- backend

volumes:
pg_data:
redis_data:
uv_cache:

networks:
backend:
Expand Down
Loading