Erik Walther Portfolio Erik Walther

Hardening my Django Portfolio

Updated: 19 Apr 2026

In this post, I’ll walk through the security architecture of my Django portfolio (erikwalther.eu), covering everything from the web server and application logic to system-level hardening and intrusion prevention.

Table of Contents

  1. Overview
  2. Web Server: Caddy & CSP
  3. Application Layer: Django & nh3
  4. Process Isolation: Gunicorn & systemd
  5. Intrusion Prevention: Fail2Ban
  6. TLDR

Overview

My stack is a classic, robust Linux combination:

Component Technology
Web Server Caddy
App Server Gunicorn
Framework Django
Database PostgreSQL
OS Fedora server
Init System systemd
IDS/IPS Fail2Ban

The goal was to build a personal portfolio that is resilient against common web attacks (XSS, CSRF, SQLi) and brute-force attempts, without relying on heavy third-party WAFs.

Web Server: Caddy & CSP

Caddy acts as the reverse proxy, handling TLS termination and serving static files. Its most critical role here is enforcing a strict Content Security Policy (CSP).

The CSP Strategy

Content-Security-Policy "default-src 'self'; 
    img-src 'self' data: https://erikwalther.eu; 
    style-src 'self'; 
    script-src 'self'; 
    connect-src 'self'; 
    font-src 'self'; 
    frame-ancestors 'none'; 
    base-uri 'self'; 
    form-action 'self';"

Why this works:

script-src 'self': This is the killer feature. It blocks all inline scripts and external JavaScript. Even if an attacker manages to inject a <script> tag into my database, the browser will refuse to execute it.
frame-ancestors 'none': Completely disables clickjacking by preventing the site from being embedded in an iframe.
form-action 'self': Ensures that any form submissions (like login forms) can only go to my own domain, mitigating CSRF attacks at the protocol level.

Header Hardening

Beyond CSP, I strip identifying headers and enforce strict transport security:

header {
    -Server
    -X-Powered-By
    X-Content-Type-Options "nosniff"
    X-Frame-Options "DENY"
    Referrer-Policy "same-origin"
    Permissions-Policy "geolocation=(), microphone=(), camera=()"
}

Removing Server and X-Powered-By obscures the tech stack, making automated scanning slightly less effective. The Permissions-Policy disables access to hardware features irrelevant to a static portfolio ensuring a little bit of privacy fr the visitor.

Application Layer: Django & nh3

While the web server handles transport security, the application layer must sanitize user input. My portfolio allows me to write project pages in Markdown, which is then converted to HTML. This is a common vector for XSS if not handled correctly.

The Markdown Pipeline

In models.py, I override the save() method of the ProjectPage model to handle conversion and sanitization:

import markdown
import nh3

def save(self, *args, **kwargs):
    # 1. Convert Markdown to HTML
    md = markdown.markdown(
        self.content_markdown,
        extensions=['fenced_code', 'codehilite', 'toc', 'tables', 'nl2br']
    )

    # 2. Sanitize with nh3
    allowed_tags = {
        'p', 'br', 'strong', 'em', 'u', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
        'ul', 'ol', 'li', 'blockquote', 'pre', 'code', 'a', 'img', 'hr',
        'table', 'thead', 'tbody', 'tr', 'th', 'td'
    }
    allowed_attrs = {
        '*': {'class', 'id'},
        'a': {'href', 'title', 'target'},
        'img': {'src', 'alt', 'title'}
    }

    # Strip disallowed tags and attributes
    self.content_html = nh3.clean(md, tags=allowed_tags, attributes=allowed_attrs)

    super().save(*args, **kwargs)

Why this matters: Even with a strict CSP, it’s best practice to sanitize data at the source. nh3 strips any tags not in the allowed_tags list (like <script>, <iframe>, or onerror handlers). This provides a second line of defense: if the CSP ever fails or is misconfigured, the malicious payload is already removed from the database.

Django Security Settings

The settings.py reinforces this with:
SECURE_SSL_REDIRECT = True: Forces HTTPS.
SECURE_HSTS_SECONDS = 31536000: Enforces HTTPS for a year (HSTS).
CSRF_COOKIE_SECURE = True: Protects CSRF tokens.
SECRET_KEY loaded via python-decouple: Ensures secrets aren't in the codebase.

Process Isolation: Gunicorn & systemd

The application server and the operating system provide the final containment layer.

Gunicorn Configuration

Gunicorn binds to a Unix socket, not a TCP port, meaning it’s not directly accessible from the network.

bind = "unix:/path/to/socket"
limit_request_line = 4094
limit_request_fields = 100
limit_request_field_size = 8190

The limit_request_* settings prevent buffer overflow attacks by rejecting oversized HTTP headers.

systemd Hardening

The gunicorn.service unit file applies aggressive sandboxing:

[Service]
User=user
Group=group
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/path/to/project /path/to/log /path/to/tmp
SELinuxContext=system_u:system_r:httpd_t:s0

ProtectSystem=strict: The entire filesystem is read-only except for explicitly defined paths.
PrivateTmp=true: The process gets its own isolated /tmp.
NoNewPrivileges=true: The process cannot escalate privileges, even if exploited.
SELinux: Adds Mandatory Access Control (MAC), restricting what the process can do even if it breaks out of the systemd sandbox.

Intrusion Prevention: Fail2Ban

Despite all the above, brute-force attacks on the SSH port and the Django admin login are inevitable. I use Fail2Ban to mitigate this.

SSH Protection

Fail2Ban monitors /path/to/SSHlog and bans IPs that fail SSH authentication too many times.

Django Admin Brute-Force Protection

Standard Fail2Ban filters don’t cover Django. I created a custom filter to detect repeated failed login attempts on the admin interface.

Filter Logic: It scans the Django log file for patterns indicating failed login attempts.

Jail Configuration:

[django-admin]
enabled = true
port = https
filter = django-auth
logpath = /path/to/log
maxretry = 5
bantime = 1h
findtime = 10m

How it works:
An attacker tries to guess the admin password. -> Django logs the failure to the configured log file. -> Fail2Ban detects 5 failures within 10 minutes. -> Fail2Ban adds an iptables/nftables rule to block the attacker's IP for 1 hour.
This effectively stops credential stuffing attacks without locking out legitimate users (who typically don't fail 5 times in 10 minutes).

TLDR

Security is a layered effort. No single tool makes a site "secure."

  1. Caddy handles transport security and blocks malicious payloads via CSP.
  2. Django & nh3 sanitize data at the application level.
  3. Gunicorn limits request sizes and isolates the process.
  4. SELinux and systemd restricts filesystem and privilege access.
  5. Fail2Ban actively blocks brute-force attackers.

By combining these layers, I’ve created a portfolio that is resilient against the vast majority of automated and manual attacks.