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.
Prerequisites
- Docker Engine 24+ and Docker Compose v2
- 2 GB RAM minimum (4 GB recommended for Suricata)
- Git to clone the lab repo
- A terminal you are comfortable in
$ 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:
| Service | Image | Port | Purpose |
|---|---|---|---|
| nginx | nginx:1.25-alpine | 8080:80 | Reverse proxy + static host |
| postgres | postgres:16-alpine | 5432 (internal) | Database backend |
| fail2ban | custom (build) | none | Brute-force protection |
| suricata | suricata/suricata:latest | none (inline) | Network IDS/IPS |
# 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.
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
# 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.
-- 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)
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.
[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).
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
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.
# 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:
# 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:
# 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
down -v deletes the PostgreSQL data volume permanently. If you want to keep your data between runs, omit the -v flag.