Home The Book LabsScripts Sign In
Labs / Hardened LN Stack
Chapter 3 FREE 30 min

Hardened LN Stack

Build a production-ready Linux + Nginx stack from scratch. Hardened per the book's standards — no shortcuts, no defaults left untouched.

bash
$ docker-compose -f labs/ln-stack.yml up -d
Creating network "labs_default" with the default driver ...
Creating labs-postgres-1 ... done
Creating labs-nginx-1    ... done
Creating labs-fail2ban-1 ... done
Creating labs-suricata-1 ... done

 PostgreSQL hardened (auth_method=scram-sha-256, ssl=on)
 Nginx with security headers
 Fail2ban configured (sshd, nginx-auth)
 Suricata inline IPS (ET PRO ruleset)
 Lab ready at http://localhost:8080

Overview

This lab walks through building a hardened Linux + Nginx (LN) stack using Docker Compose. You will configure each component according to the defense-in-depth principles covered in Chapter 3 of the book.

By the end, you will have a fully functional web server with PostgreSQL, protected by Fail2ban (brute-force mitigation) and Suricata (inline intrusion prevention). Every container is isolated, every default is changed, and every listening port is intentional.

TIP This lab uses Docker Compose for reproducibility. If you prefer bare-metal, each section includes the raw configuration you would apply directly to a host.

Prerequisites

bash
$ docker --version
Docker version 24.0.7, build afdd53b

$ docker compose version
Docker Compose version v2.23.3

$ git clone https://github.com/your-username/becauseican-labs.git
$ cd becauseican-labs

The Stack

The Docker Compose file defines four services:

ServiceImagePortPurpose
nginxnginx:1.25-alpine8080:80Reverse proxy + static host
postgrespostgres:16-alpine5432 (internal)Database backend
fail2bancustom (build)noneBrute-force protection
suricatasuricata/suricata:latestnone (inline)Network IDS/IPS
labs/ln-stack.yml
# docker-compose.yml
version: "3.9"

services:
  postgres:
    image: postgres:16-alpine
    container_name: lab-postgres
    restart: unless-stopped
    environment:
      POSTGRES_USER: lab_user
      POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme_immediately}
      POSTGRES_DB: lab_app
    volumes:
      - pgdata:/var/lib/postgresql/data
      - ./postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
    networks:
      - internal
    # No port mapping — only reachable from nginx on the internal network

  nginx:
    image: nginx:1.25-alpine
    container_name: lab-nginx
    restart: unless-stopped
    ports:
      - "8080:80"
    volumes:
      - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
      - ./nginx/security-headers.conf:/etc/nginx/snippets/security-headers.conf:ro
      - ./www:/usr/share/nginx/html:ro
    depends_on:
      - postgres
    networks:
      - internal

  fail2ban:
    build: ./fail2ban
    container_name: lab-fail2ban
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
      - NET_RAW
    volumes:
      - ./fail2ban/config:/etc/fail2ban:ro
      - /var/log:/var/log/host:ro
    networks:
      - internal

  suricata:
    image: suricata/suricata:latest
    container_name: lab-suricata
    restart: unless-stopped
    cap_add:
      - NET_ADMIN
      - NET_RAW
      - SYS_NICE
    network_mode: "host"
    volumes:
      - ./suricata/suricata.yaml:/etc/suricata/suricata.yaml:ro
      - ./suricata/rules:/var/lib/suricata/rules:ro
      - suricata-logs:/var/log/suricata

volumes:
  pgdata:
  suricata-logs:

networks:
  internal:
    driver: bridge
    internal: true

Nginx Hardening

Nginx is the public-facing component, so it gets the most hardening. The configuration below disables server version tokens, removes default pages, and applies a comprehensive set of security headers.

nginx/nginx.conf
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;

events {
    worker_connections 1024;
    # Hide server tokens in error pages
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    # Disable server version header
    server_tokens off;

    # Security headers
    include snippets/security-headers.conf;

    # Logging
    log_format main '$remote_addr - $remote_user [$time_local] '
                    '"$request" $status $body_bytes_sent '
                    '"$http_referer" "$http_user_agent"';

    access_log /var/log/nginx/access.log main;

    # Remove default server blocks — serve only what you intend
    server {
        listen 80 default_server;
        server_name _;

        root /usr/share/nginx/html;
        index index.html;

        # Block common attack paths
        location ~* /(wp-admin|wp-login|xmlrpc|phpmyadmin|\.env|\.git) {
            return 444;
        }

        # Rate limiting
        limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
        limit_req zone=general burst=20 nodelay;
    }
}

Security Headers Snippet

nginx/snippets/security-headers.conf
# Prevent MIME-type sniffing
add_header X-Content-Type-Options "nosniff" always;

# XSS protection (legacy browsers)
add_header X-XSS-Protection "1; mode=block" always;

# Prevent clickjacking
add_header X-Frame-Options "SAMEORIGIN" always;

# Control referrer leakage
add_header Referrer-Policy "no-referrer-when-downgrade" always;

# HSTS — enforce HTTPS (adjust max-age for your setup)
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

# Permissions policy — disable unused browser features
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;

# Content Security Policy
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;

PostgreSQL Hardening

PostgreSQL runs on the internal network only — no port is exposed to the host. The init script enforces SCRAM-SHA-256 authentication and creates a least-privilege application user.

postgres/init.sql
-- Revoke public schema usage by default
REVOKE ALL ON SCHEMA public FROM PUBLIC;

-- Create application user with minimal privileges
CREATE USER lab_app_user WITH PASSWORD '${APP_DB_PASSWORD}';
CREATE DATABASE lab_app OWNER lab_app_user;

-- Grant only what is needed
GRANT CONNECT ON DATABASE lab_app TO lab_app_user;
GRANT USAGE ON SCHEMA public TO lab_app_user;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO lab_app_user;
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO lab_app_user;

-- Force SCRAM-SHA-256 for all password authentication
-- (Set in postgresql.conf: password_encryption = scram-sha-256)
WARNING Never use the default changeme_immediately password in any environment — not even a local lab. Generate a real password: openssl rand -base64 32

Fail2ban Setup

Fail2ban watches Nginx access logs and SSH auth logs for repeated failures, then drops the offending IP at the firewall level. The Dockerfile and config are minimal but effective.

fail2ban/config/jail.local
[DEFAULT]
bantime  = 3600
findtime = 600
maxretry = 3
banaction = iptables-multiport

[nginx-http-auth]
enabled  = true
filter   = nginx-http-auth
logpath  = /var/log/host/nginx/access.log
port     = http,https

[nginx-botsearch]
enabled  = true
filter   = nginx-botsearch
logpath  = /var/log/host/nginx/access.log
port     = http,https

[sshd]
enabled  = true
filter   = sshd
logpath  = /var/log/host/auth.log
port     = ssh

Suricata IPS

Suricata runs in inline mode on the host network. It inspects all traffic and drops packets matching known malicious signatures. This lab uses the Emerging Threats PRO ruleset (free for non-commercial use).

suricata/suricata.yaml (excerpt)
af-packet:
  - interface: eth0
    cluster-id: 99
    cluster-type: cluster_flow
    defrag: yes
    mode: inline

default-rule-path: /var/lib/suricata/rules
rule-files:
  - suricata.rules

setuid: suricata

classification-file: /etc/suricata/classification.config
reference-config-file: /etc/suricata/reference.config

engine-analysis:
  rules-fast-pattern: yes
  rule-categories: yes
TIP Download the latest ET PRO rules: suricata-update update-sources && suricata-update. For this lab, the bundled rules cover common web attack patterns.

Validation

Run the stack and verify each component is behaving correctly.

bash
# 1. Start the stack
$ docker-compose -f labs/ln-stack.yml up -d

# 2. Verify Nginx is serving and headers are present
$ curl -I http://localhost:8080
HTTP/1.1 200 OK
Server: nginx/1.25.3
Content-Type: text/html; charset=UTF-8
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Content-Security-Policy: default-src 'self'; ...

# 3. Verify PostgreSQL is not reachable from the host
$ curl localhost:5432 2>&1 | head -1
curl: (7) Failed to connect to localhost port 5432: Connection refused

# 4. Check Fail2ban status
$ docker exec lab-fail2ban fail2ban-client status
Status
|- Number of jail:      3
`- Jail list:   nginx-botsearch nginx-http-auth sshd

# 5. Verify Suricata is running in inline mode
$ docker exec lab-suricata suricata -T -c /etc/suricata/suricata.yaml
20/12/2026 -- 14:32:01 - <Info> - Configuration provided was valid.

Trigger a Fail2ban ban

Simulate a brute-force attempt to confirm Fail2ban is watching and responding:

bash
# Send 5 requests to a blocked path rapidly
$ for i in $(seq 1 5); do
    curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/.env
  done
444
444
444
444
444

# Check if Fail2ban banned the IP
$ docker exec lab-fail2ban fail2ban-client status nginx-botsearch
Status for the jail: nginx-botsearch
|- Filter
|  |- Currently failed: 1
|  |- Total failed:     5
|  `- File list:        /var/log/host/nginx/access.log
`- Actions
   |- Currently banned: 1
   |- Total banned:     1
   `- Banned IP list:   172.18.0.1

Cleanup

When you are done, tear everything down cleanly:

bash
# Stop and remove containers + networks
$ docker-compose -f labs/ln-stack.yml down

# Remove volumes (wipes PostgreSQL data)
$ docker-compose -f labs/ln-stack.yml down -v

# Remove built images
$ docker-compose -f labs/ln-stack.yml down --rmi local
WARNING Running down -v deletes the PostgreSQL data volume permanently. If you want to keep your data between runs, omit the -v flag.
← Back to Labs
No trackers Self-hosted E2E encrypted