VibeWarden + Django¶
This guide shows how to put VibeWarden in front of a Django application running in Docker Compose. VibeWarden handles TLS, authentication (Ory Kratos), rate limiting, and security headers. Django receives only authenticated, validated requests with identity injected as HTTP headers.
Architecture¶
Internet
│
▼ :443 (HTTPS)
VibeWarden (Caddy embedded)
│ validates session cookie → Kratos
│ injects X-User-* headers
▼ :8000 (HTTP, internal, via Gunicorn)
Django app
Your Django app listens on port 8000 on the internal Docker network via Gunicorn. It is never directly reachable from the internet — VibeWarden is the sole entry point.
vibewarden.yaml¶
server:
host: "0.0.0.0"
port: 443
upstream:
host: "django" # Docker Compose service name
port: 8000
tls:
enabled: true
provider: letsencrypt
domain: "myapp.example.com"
kratos:
public_url: "http://kratos:4433"
admin_url: "http://kratos:4434"
auth:
public_paths:
- "/login"
- "/register"
- "/recovery"
- "/verification"
- "/static/*"
- "/media/*"
- "/favicon.ico"
- "/robots.txt"
- "/api/public/*"
session_cookie_name: "ory_kratos_session"
login_url: "/login"
body_size:
max: "10MB"
overrides:
- path: /api/upload
max: "50MB"
rate_limit:
enabled: true
per_ip:
requests_per_second: 20
burst: 40
per_user:
requests_per_second: 200
burst: 400
trust_proxy_headers: false
log:
level: "info"
format: "json"
admin:
enabled: true
token: "" # set via VIBEWARDEN_ADMIN_TOKEN env var
metrics:
enabled: true
path_patterns:
- "/api/:resource"
- "/api/:resource/:id"
- "/api/v1/:resource"
- "/api/v1/:resource/:id"
security_headers:
enabled: true
hsts_max_age: 31536000
hsts_include_subdomains: true
content_type_nosniff: true
frame_option: "DENY"
content_security_policy: "default-src 'self'"
referrer_policy: "strict-origin-when-cross-origin"
cross_origin_opener_policy: "same-origin"
cross_origin_resource_policy: "same-origin"
suppress_via_header: true
docker-compose.yml¶
services:
postgres:
image: postgres:17-alpine
container_name: myapp-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- myapp
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
# App-level Postgres (separate DB for your Django app)
app-postgres:
image: postgres:17-alpine
container_name: myapp-app-postgres
restart: unless-stopped
environment:
POSTGRES_USER: ${APP_DB_USER}
POSTGRES_PASSWORD: ${APP_DB_PASSWORD}
POSTGRES_DB: ${APP_DB_NAME}
volumes:
- app_postgres_data:/var/lib/postgresql/data
networks:
- myapp
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${APP_DB_USER} -d ${APP_DB_NAME}"]
interval: 5s
timeout: 5s
retries: 10
kratos-migrate:
image: oryd/kratos:v1.3.1
container_name: myapp-kratos-migrate
restart: on-failure
environment:
DSN: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
volumes:
- ./config/kratos:/etc/config/kratos:ro
command: migrate sql -e --yes
depends_on:
postgres:
condition: service_healthy
networks:
- myapp
kratos:
image: oryd/kratos:v1.3.1
container_name: myapp-kratos
restart: unless-stopped
environment:
DSN: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
SERVE_PUBLIC_BASE_URL: https://${DOMAIN}/
SERVE_ADMIN_BASE_URL: http://kratos:4434/
volumes:
- ./config/kratos:/etc/config/kratos:ro
command: serve --config /etc/config/kratos/kratos.yml --watch-courier
depends_on:
kratos-migrate:
condition: service_completed_successfully
networks:
- myapp
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://kratos:4434/admin/health/ready"]
interval: 10s
timeout: 5s
retries: 10
start_period: 10s
vibewarden:
image: ghcr.io/vibewarden/vibewarden:latest
container_name: myapp-vibewarden
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./vibewarden.yaml:/vibewarden.yaml:ro
- caddy_data:/root/.local/share/caddy
environment:
VIBEWARDEN_KRATOS_PUBLIC_URL: http://kratos:4433
VIBEWARDEN_KRATOS_ADMIN_URL: http://kratos:4434
VIBEWARDEN_UPSTREAM_HOST: django
VIBEWARDEN_UPSTREAM_PORT: "8000"
VIBEWARDEN_SERVER_HOST: "0.0.0.0"
VIBEWARDEN_ADMIN_TOKEN: ${VIBEWARDEN_ADMIN_TOKEN}
depends_on:
kratos:
condition: service_healthy
django:
condition: service_healthy
networks:
- myapp
django:
image: your-registry/your-django-app:latest
container_name: myapp-django
restart: unless-stopped
environment:
DJANGO_SETTINGS_MODULE: myapp.settings.production
DATABASE_URL: postgres://${APP_DB_USER}:${APP_DB_PASSWORD}@app-postgres:5432/${APP_DB_NAME}
SECRET_KEY: ${DJANGO_SECRET_KEY}
expose:
- "8000"
networks:
- myapp
depends_on:
app-postgres:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/health/"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:
app_postgres_data:
caddy_data:
networks:
myapp:
driver: bridge
Django has its own application database (
app-postgres) separate from the Kratos database (postgres). This separation keeps concerns clean.
Reading X-User-* headers in Django¶
VibeWarden injects identity headers on every authenticated request before forwarding
to the upstream. Django receives them as HTTP headers; the WSGI/ASGI interface
transforms header names to uppercase with an HTTP_ prefix.
Available headers¶
| HTTP Header | Django request.META key |
Description |
|---|---|---|
X-User-ID |
HTTP_X_USER_ID |
Kratos identity UUID |
X-User-Email |
HTTP_X_USER_EMAIL |
Primary email address |
X-User-Verified |
HTTP_X_USER_VERIFIED |
"true" if email is verified |
X-Session-ID |
HTTP_X_SESSION_ID |
Kratos session UUID |
Middleware¶
Create a Django middleware that reads the VibeWarden headers and attaches a user
object to request.vw_user:
# myapp/middleware.py
from dataclasses import dataclass
@dataclass(frozen=True)
class VibeWardenUser:
"""Represents the authenticated user as injected by VibeWarden."""
id: str
email: str
verified: bool
session_id: str
class VibeWardenMiddleware:
"""
Reads X-User-* headers injected by VibeWarden and attaches a
VibeWardenUser to request.vw_user.
Only trust these headers when all requests come through VibeWarden.
Never expose your Django app directly to the internet.
"""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
user_id = request.META.get("HTTP_X_USER_ID", "")
user_email = request.META.get("HTTP_X_USER_EMAIL", "")
verified = request.META.get("HTTP_X_USER_VERIFIED", "false") == "true"
session_id = request.META.get("HTTP_X_SESSION_ID", "")
if user_id:
request.vw_user = VibeWardenUser(
id=user_id,
email=user_email,
verified=verified,
session_id=session_id,
)
else:
request.vw_user = None
return self.get_response(request)
Register the middleware in settings.py:
# settings/production.py
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"myapp.middleware.VibeWardenMiddleware", # add after standard middleware
# ...
]
Views¶
# views.py
from django.http import JsonResponse
from django.views import View
class MeView(View):
def get(self, request):
user = request.vw_user
if not user:
return JsonResponse({"error": "unauthorized"}, status=401)
return JsonResponse({
"id": user.id,
"email": user.email,
"verified": user.verified,
})
class DashboardView(View):
def get(self, request):
user = request.vw_user
if not user:
return JsonResponse({"error": "unauthorized"}, status=401)
return JsonResponse({"message": f"Hello, {user.email}"})
Decorator for protected views¶
# myapp/decorators.py
from functools import wraps
from django.http import JsonResponse
def vibewarden_required(view_func):
"""
Decorator that rejects requests where VibeWarden did not inject
a user identity (i.e. the request is unauthenticated).
"""
@wraps(view_func)
def wrapper(request, *args, **kwargs):
if not getattr(request, "vw_user", None):
return JsonResponse({"error": "unauthorized"}, status=401)
return view_func(request, *args, **kwargs)
return wrapper
Usage:
from myapp.decorators import vibewarden_required
@vibewarden_required
def profile(request):
return JsonResponse({
"id": request.vw_user.id,
"email": request.vw_user.email,
})
Django REST Framework integration¶
If you use DRF, create a custom authentication class:
# myapp/authentication.py
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from dataclasses import dataclass
@dataclass
class VibeWardenIdentity:
"""DRF-compatible user object populated from VibeWarden headers."""
pk: str # required by DRF's is_authenticated logic
email: str
verified: bool
session_id: str
@property
def is_authenticated(self):
return True
@property
def is_anonymous(self):
return False
class VibeWardenAuthentication(BaseAuthentication):
"""
DRF authentication backend that reads X-User-* headers injected
by VibeWarden. Returns (user, None) on success.
"""
def authenticate(self, request):
user_id = request.META.get("HTTP_X_USER_ID")
if not user_id:
return None # unauthenticated; let DRF handle it
user = VibeWardenIdentity(
pk=user_id,
email=request.META.get("HTTP_X_USER_EMAIL", ""),
verified=request.META.get("HTTP_X_USER_VERIFIED", "false") == "true",
session_id=request.META.get("HTTP_X_SESSION_ID", ""),
)
return (user, None)
Register in settings.py:
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"myapp.authentication.VibeWardenAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
}
DRF views then use request.user as normal:
from rest_framework.views import APIView
from rest_framework.response import Response
class ProfileView(APIView):
def get(self, request):
return Response({
"id": request.user.pk,
"email": request.user.email,
})
Django settings for production behind a proxy¶
Since VibeWarden terminates TLS and forwards HTTP to Django, configure Django to trust the forwarded headers:
# settings/production.py
# VibeWarden is the only trusted proxy; it runs on the same Docker network.
# Set this to the VibeWarden container's IP or the Docker network CIDR.
ALLOWED_HOSTS = ["myapp.example.com"]
# Tell Django that HTTPS is being handled by VibeWarden upstream.
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Django's own HSTS and security headers — disable these because VibeWarden
# already adds them. Letting both add headers results in duplicates.
SECURE_HSTS_SECONDS = 0
SECURE_CONTENT_TYPE_NOSNIFF = False
SECURE_BROWSER_XSS_FILTER = False
X_FRAME_OPTIONS = "" # VibeWarden sets X-Frame-Options
# Do not redirect HTTP to HTTPS in Django — VibeWarden handles that.
SECURE_SSL_REDIRECT = False
# CSRF — Django still validates CSRF tokens for state-mutating requests.
# VibeWarden does not bypass CSRF protection.
CSRF_TRUSTED_ORIGINS = ["https://myapp.example.com"]
Health check endpoint¶
Add a lightweight health endpoint that does not touch the database:
# urls.py
from django.urls import path
from django.http import JsonResponse
def health(request):
return JsonResponse({"status": "ok"})
urlpatterns = [
path("health/", health),
# ... your other URL patterns
]
Register /health/ in auth.public_paths in vibewarden.yaml so VibeWarden
passes it through without a session check.