Simple BOT blocker with Caddy and Django

Source: Dev.to | Dec 17, 2025 (UTC)

Tired of seeing this in your Django logs?

They do no harm because Django handles them properly based on allowed hosts env, but those bot requests pass the reverse-proxy and end up in the Python application – which is not as fast as a reverse proxy built in Go, C, C++, Zig or something else.

Here is how to stop those bot requests using just a django command which generates the Caddyfile template.

The generated Caddyfile will just block by default all routes that are not registered by Django and will block any hosts other that the one specified (ex: bot trying to curl on VPS IP).

The Caddyfile template

We’ll use Django Templates to pass existing paths to Caddyfile template.

# Catch any request not matching your domain (like IP scans) :80, :443 { abort } {{ app_url }} { # Create a named matcher for the host @strict_host { not host {{ app_url }} } # Abort if the host isn't exactly yours handle @strict_host { abort } # Enable high-performance compression encode zstd gzip # 1. Static Files (Update with path to staticfiles) # Handled first to ensure they are always accessible. handle_path /static/* { root * /webapp/staticfiles file_server } # 2. Define Allowed Routes # This matcher includes all Django URLs plus your explicit SSE route. @allowed { path /favicon.ico path /robots.txt path /sitemap.xml path /sse-events* {% for route in routes %} path {{ route }} {% endfor %} } # 3. Handle Allowed Traffic handle @allowed { # SSE Sidecar (Specific route handling inside the allowed block) reverse_proxy /sse-events* docker-service-name-sse-sidecar:5687 # Main Django App (Default for other allowed routes) reverse_proxy docker-service-name-app:8000 } # 4. Fallback: Block everything else handle { respond "Access Denied" 403 } }

Enter fullscreen mode

Exit fullscreen mode

The Django Command

In any app create a new caddyfile_gen.py in your_app/management/commands folder.

Paste this code there in caddyfile_gen.py

from django.core.management.base import BaseCommand from django.template.loader import render_to_string from django.urls import get_resolver from django.urls.resolvers import URLPattern, URLResolver class Command(BaseCommand): help = "Generates a Caddyfile based on registered Django URLs" def handle(self, *args, **options): # You can set your domain name in env app_url = "chatcodfiscal.ro" resolver = get_resolver() raw_routes = self.get_urls(resolver.url_patterns) final_routes = set() for r in raw_routes: final_routes.add(r) # ex: "/chat/" -> adds "/chat" if r.endswith("/") and len(r) > 1: final_routes.add(r.rstrip("/")) # Remove duplicates clean_routes = sorted(list(set(final_routes))) context = {"app_url": app_url, "routes": clean_routes} # Assumes caddyfile.tpl is in your templates/components folder caddyfile_content = render_to_string("components/caddyfile.tpl", context) with open("Caddyfile", "w") as f: f.write(caddyfile_content) print("Caddyfile was created!") def get_urls(self, patterns, prefix=""): urls = [] for pattern in patterns: if isinstance(pattern, URLPattern): path_str = str(pattern.pattern) full_path = prefix + path_str # 1. Remove Regex anchors full_path = full_path.replace("^", "").replace("$", "") # 2. Ensure it starts with / if not full_path.startswith("/"): full_path = "/" + full_path if full_path.startswith("/admin"): urls.append("/admin") urls.append("/admin/*") continue if "<" in full_path: full_path = full_path.split("<")[0] + "*" urls.append(full_path) elif isinstance(pattern, URLResolver): # Recursively handle included URLconfs new_prefix = prefix + str(pattern.pattern) urls.extend(self.get_urls(pattern.url_patterns, new_prefix)) return urls

Enter fullscreen mode

Exit fullscreen mode

A Caddyfile file will be generated and it will look similar to this one:

# Catch any request not matching your domain (like IP scans) :80, :443 { abort } your-domain.com { # Create a named matcher for the host @strict_host { not host your-domain.com } # Abort if the host isn't exactly yours handle @strict_host { abort } # Enable high-performance compression encode zstd gzip # 1. Static Files # Handled first to ensure they are always accessible. handle_path /static/* { root * /webapp/staticfiles file_server } # 2. Define Allowed Routes # This matcher includes all Django URLs plus your explicit SSE route. @allowed { path /favicon.ico path /robots.txt path /sitemap.xml path /sse-events* path / path /admin path /admin/* path /some-path path /some-path/ path /legal path /legal/ path /login/* path /logout path /logout/ path /meniu path /meniu/ path /robots.txt path /sitemap.xml path /sse-token path /sse-token/ } # 3. Handle Allowed Traffic handle @allowed { # SSE Sidecar (Specific route handling inside the allowed block) reverse_proxy /sse-events* docker-service-name-sse-sidecar:5687 # Main Django App (Default for other allowed routes) reverse_proxy docker-service-name-app:8000 } # 4. Fallback: Block everything else handle { respond "Access Denied" 403 } }

Enter fullscreen mode

Exit fullscreen mode

The Docker Compose file

In your docker-compose.yml file make sure you don’t expose ports to external network.

# DEV # ports: # - "8000:8000" # PROD - expose port only inside docker network expose: - "8000"

Enter fullscreen mode

Exit fullscreen mode

And now all those BOT requests will just stop at Caddy level!

For further actions, you may consider blocking this person and/or reporting abuse

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *

Читайте також