Project Goal: A Secure, Automated, Multi-Site Hosting Environment
This guide documents the end-to-end process of setting up a secure, multi-site hosting environment on a Debian 12 VPS. The journey begins with a simple reverse proxy setup and evolves into a sophisticated, automated pipeline that builds a custom, WAF-enabled version of Nginx Proxy Manager.
Initial State:
-
A Debian 12 VPS with key-based SSH access.
-
UFW firewall, Docker, and Tailscale VPN installed.
-
Website code hosted in a private Forgejo (Git) repository.
-
Domain DNS managed via Cloudflare.
Phase 1: Initial VPS & Website Setup
The first phase focused on getting a single website online securely using a standard reverse proxy architecture.
1.1. Preparing for Secure Code Deployment
To avoid using personal SSH keys on the server, a read-only deploy key was created for the Git repository.
-
On the VPS: A new SSH key pair was generated specifically for deployment.
ssh-keygen -t ed25519 -f ~/.ssh/forgejo_deploy_key
-
In Forgejo: The public key (
~/.ssh/forgejo_deploy_key.pub
) was added to the website’s repository under Settings > Deploy Keys, with write access left unchecked.
1.2. Setting Up the Reverse Proxy (Nginx Proxy Manager)
We used Nginx Proxy Manager (NPM) to act as a reverse proxy, handling all incoming web traffic and directing it to the correct website container.
-
Directory Structure: A directory was created on the host at
/opt/npm
. -
Docker Compose: The following
docker-compose.yml
was used to run NPM. The admin port (81
) was intentionally bound to127.0.0.1
to make it accessible only via the secure Tailscale VPN.# /opt/npm/docker-compose.yml services: npm: image: 'jc21/nginx-proxy-manager:latest' container_name: npm restart: unless-stopped ports: - '80:80/tcp' - '443:443/tcp' - '443:443/udp' # Added later for HTTP/3 - '127.0.0.1:8181:81' # Admin panel volumes: - ./data:/data - ./letsencrypt:/etc/letsencrypt
1.3. Firewall and DNS Configuration
-
UFW: Ports 80 (HTTP) and 443 (HTTPS) were opened.
sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw reload
-
Cloudflare: An
A
record was created for the domain, pointing to the VPS’s public IP address. The “Proxy status” was initially set to DNS only (grey cloud) to allow for direct SSL certificate validation by NPM.
1.4. Deploying a Static Website Container
Each website is deployed as its own Docker container, making it isolated and easy to manage.
-
Code: The website code was cloned from Forgejo into
/opt/sites/my-simple-site
. -
Dockerfile: A simple
Dockerfile
was created inside the site’s directory to serve the static files using Nginx.FROM nginx:stable-alpine COPY . /usr/share/nginx/html EXPOSE 80
-
Docker Compose: A
docker-compose.yml
was created to build and run the site, connecting it to the same Docker network as NPM.# /opt/sites/my-simple-site/docker-compose.yml services: web: build: . container_name: my-simple-site restart: unless-stopped networks: - npm_default networks: npm_default: external: true
-
Linking in NPM: Inside the NPM web UI (accessed via Tailscale), a Proxy Host was created.
-
Domain:
www.example.com
-
Forward Hostname:
my-simple-site
(the container name) -
Forward Port:
80
-
SSL: A Let’s Encrypt certificate was requested, and “Force SSL” was enabled.
-
Phase 2: Hardening the Setup
With the site online, we layered on several security enhancements.
-
Cloudflare:
-
The DNS record’s proxy status was toggled to Proxied (orange cloud) to hide the server’s IP and enable Cloudflare’s protections.
-
The SSL/TLS mode was set to Full (Strict) to ensure end-to-end encryption.
-
The Web Application Firewall (WAF) was enabled to block common attacks.
-
-
Nginx Proxy Manager:
- Security Headers (like
Strict-Transport-Security
,X-Frame-Options
, andContent-Security-Policy
) were added via the “Advanced” tab of the proxy host to protect against browser-level attacks like clickjacking and XSS. This proved difficult and led to the final architecture.
- Security Headers (like
-
Host & Container Security:
-
Fail2Ban was installed on the host to protect the SSH port.
-
Unattended Upgrades were enabled for automatic OS security patches.
-
The website’s
Dockerfile
was updated to run the Nginx process as a non-root user. -
Log Rotation was configured on the host using
logrotate
to manage the NPM log files and prevent disk space exhaustion.
-
Phase 3: Evolving to an Advanced Security Posture with CrowdSec
To move beyond passive security and implement an active Intrusion Prevention System (IPS), CrowdSec was added to the stack. This phase involves two key components for layered protection.
3.1. Setting up the CrowdSec Agent (in Docker)
The CrowdSec Agent is the “brain” of the operation. It runs in a Docker container, reads logs from various sources, detects malicious behavior, and manages the blocklist.
- Implementation: The
crowdsec
service was added to the maindocker-compose.yml
file, with volumes mounted to give it read-only access to the necessary host log files.
3.2. Installing the Host Firewall Bouncer
The Firewall Bouncer is the “shield.” It’s a service installed directly on the host OS that communicates with the CrowdSec agent and uses the system firewall (iptables
) to block all traffic from banned IPs at the network level (Layer 3/4). This is the most efficient way to block attacks.
-
Add CrowdSec Repository: First, add the official CrowdSec package repository to your Debian host.
curl -s https://packagecloud.io/install/repositories/crowdsec/crowdsec/script.deb.sh | sudo bash
-
Install the Bouncer: Update your package list and install the
iptables
version of the firewall bouncer.sudo apt update sudo apt install crowdsec-firewall-bouncer-iptables
-
Verify the Bouncer: The bouncer should automatically detect the running CrowdSec agent (even in Docker) and register itself. You can verify this from your
docker-compose
directory:# From ~/sites/npm docker compose exec crowdsec cscli bouncers list
You should see
host-firewall-bouncer
in the list with a valid status, confirming that your host’s firewall is now actively protecting your server.
Phase 4: The Final Architecture - An Automated, WAF-Enabled Proxy
The final goal was to integrate CrowdSec’s application-level protection (AppSec/WAF) directly into the reverse proxy. This provides more granular control than the firewall bouncer, allowing for actions like presenting a CAPTCHA instead of a hard block.
The Challenge & The Solution
Initial attempts to install the CrowdSec Nginx Bouncer into the official NPM image failed due to fundamental incompatibilities.
The final, successful strategy was to build a completely new, self-contained Docker image from scratch that combines the best official components:
-
OpenResty: A modern distribution of Nginx that comes with Lua support built-in.
-
Nginx Proxy Manager: The official backend and frontend source code.
-
CrowdSec Lua Bouncer: The official bouncer library.
The Automation Pipeline (GitHub)
A professional CI/CD pipeline was created in a GitHub repository (buildplan/cs-ngx
) to automate the building and publishing of this custom image.
-
The
Dockerfile
: A multi-stageDockerfile
was engineered to build each component in its own clean environment and then copy only the finished artifacts into a minimal final image. This ensures the image is small, secure, and reproducible. -
The GitHub Actions Workflow: A
build-and-push.yml
workflow was created to build theDockerfile
and publish the final image to GitHub Container Registry (GHCR) atghcr.io/buildplan/cs-ngx
. -
Dependabot: A
dependabot.yml
file was added to automatically create pull requests when the base images (likenode
oropenresty
) have updates, ensuring the entire stack stays current.
The Final Implementation on the VPS
The setup on the VPS was simplified to use this new custom-built image.
-
Configuration Files: Instead of a custom entrypoint, we adopted the official NPM method for customization.
-
A bouncer config file (
~/sites/npm/crowdsec/bouncer.conf
) was created to hold the API key. -
An Nginx snippet (
~/sites/npm/custom/crowdsec.conf
) was created to activate the bouncer. NPM automatically loads any file placed in/data/nginx/custom/http_top.conf
inside the container.
-
-
The Final
docker-compose.yml
: The compose file was updated to use the new image from GHCR and mount the configuration files into the correct locations.# ~/sites/npm/docker-compose.yml services: npm: image: ghcr.io/buildplan/cs-ngx:latest container_name: npm-appsec pull_policy: always restart: unless-stopped ports: - "80:80" - "443:443" - "443:443/udp" - "127.0.0.1:8181:81" volumes: # Standard NPM data volumes - ./data:/data - ./letsencrypt:/etc/letsencrypt # Mount the bouncer config with the API key - ./crowdsec/bouncer.conf:/etc/crowdsec/bouncers/crowdsec-nginx-bouncer.conf:ro # Mount the Nginx snippet to activate the bouncer - ./custom/crowdsec.conf:/data/nginx/custom/http_top.conf:ro crowdsec: image: crowdsecurity/crowdsec:latest container_name: crowdsec restart: unless-stopped volumes: - ./crowdsec/config:/etc/crowdsec/ - ./crowdsec/data:/var/lib/crowdsec/data/ - /var/log:/var/log/host:ro # For host-level protection environment: - COLLECTIONS=crowdsecurity/linux crowdsecurity/sshd - TZ=Europe/London - GID=0
This final architecture represents a secure, modern, maintainable, and fully automated system for hosting multiple websites, complete with network-level and application-level intrusion prevention.