Home/Self-Hosting Guide
Full Stack Guide

Self-host multiple websites behind pfSense

From choosing the right internet connection to configuring the firewall, running a Docker host with a reverse proxy, and hardening every layer — here's the complete stack for hosting your own production websites from your own premises.

Internet public IP Your visitors browsers · DNS → 203.0.113.22 ISP Modem BRIDGE MODE Airtel / Jio / ACT PPPoE passthrough pfSense Netgate 2100 / 4200 WAN LAN + HAProxy · ACME LAN Switch 1 GbE / 10 GbE VLAN 10 · DMZ Hosting Manager Ubuntu 22.04 LTS 192.168.10.20 Coolify / CapRover / Dokku site1.com nginx docker:8001 site2.com php-fpm docker:8002 site3.com node · express docker:8003 fibre / DSL PPPoE 192.168.10.0/24 HTTPS 443 public (WAN) private (LAN) container network
The principle Your ISP modem becomes a dumb pipe (bridge mode). pfSense becomes the brain. Inside the LAN, a Docker host runs N websites and a local reverse proxy. pfSense's HAProxy receives every request from the internet on 443, terminates TLS with Let's Encrypt, and forwards to the Docker host by Host header. Add new sites by editing two files — no new public IP needed.
Step 01

Choose the right ISP connection

For self-hosting you need two things: a static public IP (so DNS can point at you) and unblocked ports 80 and 443 (most home broadband blocks them). Here's how the common options compare in India.

Connection typeSpeed (down/up)Static IP80/443 openSLAMonthly cost (INR)Best for
Home Broadband (retail)100-500 / shared✕ Not usually✕ Often blockedNone₹700-1,500Not viable for hosting
Home Broadband + Static IP add-on100-1000 / shared✓ (add-on ~₹500)✓ Check ISPNone₹1,200-2,500Dev, low-traffic sites
Business Broadband (Jio, Airtel Biz)200-1000 / symmetric or 1:4✓ Included✓ Open99%₹2,500-6,000SMB production
Fibre Leased Line 1:110-1000 / symmetric✓ Multiple /29✓ Open99.5-99.9%₹6,000-25,000Business critical
MPLS / dedicated ILL10-10,000 / symmetric✓ /29 or /28✓ Open99.95% + finite₹25,000-2,00,000+Enterprise / multi-site
Ask the ISP before signing 1. Do you provide a static public IP? (ask in writing — "dynamic with DDNS" is not the same)
2. Are ports 80 and 443 unblocked on the WAN? (retail consumer connections often block inbound)
3. Upload speed? — matters more than download for hosting. Leased lines are 1:1 symmetric
4. PPPoE, DHCP or static IPoE? — determines how pfSense will authenticate
5. CGNAT? — if yes, self-hosting is impossible without a VPN tunnel to a public-IP server
Recommended starting point Jio Fiber Business (200 Mbps + static IP) at ₹2,500/month or Airtel Xtream Business (200 Mbps + static IP) at ₹2,799/month. Both unblock 80/443 and give 99% uptime. Upgrade to a leased line only when you exceed ~500 concurrent users or need proper SLA.
Step 02

Put the ISP modem in bridge mode

The modem your ISP installs also acts as a router with its own NAT. If you leave it that way you get double NAT — pfSense can't see the public IP, can't issue Let's Encrypt certs, and port forwards get painful. Solution: put the modem in bridge mode so it passes the public session to pfSense.

Collect credentials

Login to the ISP modem (usually 192.168.1.1 or 192.168.0.1). Admin password is on the sticker or provided at install. Write down the PPPoE username + password — you'll type it into pfSense.

Find the WAN / Internet section

Look for Operation Mode, WAN Setup or Connection Type. Change from Router mode / NATBridge mode (sometimes called "transparent bridging" or "passthrough").

Disable WiFi on the modem

You don't want devices connecting around pfSense. Turn off both 2.4 GHz and 5 GHz radios. Disable DHCP while you're there.

Save and reboot

After saving, the modem reboots. Your LAN loses internet until pfSense is configured in Step 3.

Vendor-specific notes (India) Airtel Xtream (Nokia G-240W): Network → WAN → Mode: Bridge. Some firmwares hide the option — call support and ask them to enable "bridge mode" on their side.
Jio Fiber (Prolink/TP-Link): not all plans allow bridge; newer routers do. Check Advanced → Network → WAN type.
ACT Fibernet: they provide a managed switch, just plug pfSense WAN into it. No bridge needed — you're already bridged.
BSNL FTTH: Tenda / D-Link ONTs. Advanced → WAN → Connection Type: Bridge. PPPoE auth moves to pfSense.
Step 03

Configure pfSense WAN (PPPoE or static)

Plug the ISP modem's output into pfSense's WAN port. Then configure pfSense to authenticate to the ISP.

Open WAN interface settings

Interfaces › WAN

Set IPv4 Configuration Type

For most Indian fibre: PPPoE. For business broadband with static IP: Static IPv4. For ACT/managed-switch setups: DHCP.

Enter PPPoE credentials

Username + Password from the ISP. Leave Service name blank unless the ISP specified one.

PPPoE Username: [email protected]
PPPoE Password: ••••••••
Service name: (leave blank)
Dial on demand: unchecked (always connected)

Block private + bogon networks

At the bottom of the same page, tick Block private networks and Block bogon networks. Standard production hardening.

Save & verify

Go to Status › Interfaces. The WAN should show a public IP. Run curl ifconfig.me from a LAN client — it should return the same IP.

Status › Interfaces › WAN
Static IP path (business broadband) If ISP gave you a static IP (e.g. 203.0.113.22): IPv4 Configuration Type = Static IPv4, IPv4 Address = your IP / subnet mask (usually /30 or /29), Upstream Gateway = the ISP's gateway IP. Add A record for your domain(s) → 203.0.113.22.
Step 04

Port forward 80 + 443 to pfSense itself

Because we'll run HAProxy on pfSense (Step 5), we forward 80 and 443 directly to the firewall's WAN IP — not to an internal server. HAProxy then handles the Host-header routing to the right backend.

Why HAProxy on pfSense (not on the Docker host)? Putting the reverse proxy on pfSense keeps TLS termination at the edge — the Docker host never gets raw internet traffic. It also lets Let's Encrypt's ACME package (also on pfSense) manage certificates without extra plumbing. You can do it the other way (HAProxy inside LAN) but this is cleaner for most deployments.

Add Firewall rule on WAN

Firewall › Rules › WAN › Add
Action: Pass
Interface: WAN
Protocol: TCP
Source: any
Destination: WAN address
Destination Port Range: HTTP (80) to HTTPS (443)
Description: Public web traffic to HAProxy

(No NAT port-forward needed)

Since HAProxy binds on pfSense directly, there's no separate backend host. Just the WAN firewall rule above.

Step 05

HAProxy + Let's Encrypt on pfSense

Install two packages: haproxy-devel (modern HAProxy build) and acme (Let's Encrypt client). HAProxy handles HTTP(S) reverse-proxying with SNI-based host routing; ACME auto-issues + renews your certificates.

Install the packages

System › Package Manager › Available

Install haproxy-devel and acme. Reboot not required.

Enable HAProxy

Services › HAProxy › Settings

Tick Enable HAProxy, set Max SSL Diffie-Hellman to 2048, save.

Issue a wildcard Let's Encrypt cert

Services › Acme Certificates › Certificates

Add a new certificate for *.yourdomain.com using DNS-01 validation (your domain's API — Cloudflare, GoDaddy, Route53 all supported). With a wildcard you cover all subdomains with one cert.

Create backends (one per site)

Services › HAProxy › Backend › Add

Each backend points to your Docker host on a different container port. Example below.

Create a single shared frontend on :443

Services › HAProxy › Frontend › Add

Listen on WAN address :443 with SSL offloading using the wildcard cert. Under Access Control Lists add one rule per site matching the Host header. Under Actions route each matched ACL to its backend.

Add a :80 → :443 redirect frontend

Separate frontend on WAN :80 with a single action: Redirect scheme to https code 301. So any plain-HTTP visitor jumps to HTTPS.

Apply + test

Click Apply. Hit https://site1.yourdomain.com — browser should show green lock + your site's content from the Docker host.

Example HAProxy backend (1 of 3)
Name               site1_backend
Server list
  Name             docker-host
  Address          192.168.10.20
  Port             8001            # container's published port
  SSL              (unchecked)     # LAN traffic stays plain HTTP
  Verify SSL cert  (unchecked)
Check method       HTTP
HTTP check URI     /
HTTP check host    site1.yourdomain.com
Frontend :443 — ACL + action snippet
# Access Control Lists — Host header match
ACL 1  Name: host_site1  Expression: Host matches: site1.yourdomain.com
ACL 2  Name: host_site2  Expression: Host matches: site2.yourdomain.com
ACL 3  Name: host_site3  Expression: Host matches: site3.yourdomain.com

# Actions — route matched ACL to backend
Action 1  Use backend: site1_backend  Condition ACL: host_site1
Action 2  Use backend: site2_backend  Condition ACL: host_site2
Action 3  Use backend: site3_backend  Condition ACL: host_site3

# Default backend (for unmatched hosts) — a "not found" page
Default backend  default_404
Step 06

Hosting manager for the sites

Inside the LAN, a small Ubuntu server runs N containers — each serving one website on its own internal port. You can manage the containers by hand with Docker Compose, or install a free hosting manager that gives you a Heroku-style web UI over the same Docker engine. The host doesn't need a public IP; pfSense's HAProxy fronts everything.

Provision hardware

Any recent PC / mini-PC / NUC with 16 GB RAM + 512 GB SSD is plenty for 20+ static or WordPress sites. Pick a box with at least one 1 GbE NIC.

Install Ubuntu 22.04 LTS + Docker

Give it a static DHCP lease from pfSense (192.168.10.20). SSH in and run:

install-docker.sh
# System updates + base tools
sudo apt update && sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg unattended-upgrades

# Docker engine + Compose (official convenience script)
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
sudo systemctl enable --now docker

# Sanity check
docker --version
docker compose version
Optional · Recommended for most

Install a free hosting manager on top

Raw Docker Compose works, but becomes tedious once you cross 3–4 sites. A hosting manager wraps the same Docker engine with a web UI — click-to-deploy, Let's Encrypt automation, one-click restarts, and log viewers. All four below are fully free and self-hosted.

Coolify

★ Recommended

The modern Heroku / Netlify replacement. Dashboard deploys Git repos, Docker images, databases, and static sites. Built-in Traefik, automatic SSL, team invitations.

  • Free & open-source (MIT)
  • Deploy from GitHub / GitLab / Bitbucket
  • One-click Postgres, MySQL, Redis, MongoDB
  • Built-in backup + monitoring
  • ~2 GB RAM overhead
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | sudo bash

CapRover

Classic

Long-running, well-documented. Web UI + CLI. Built on Docker Swarm, so scaling to multiple nodes is straightforward.

  • Free & open-source (Apache 2.0)
  • One-click apps (WordPress, Ghost, Mattermost, 100+)
  • Automatic HTTPS via Let's Encrypt
  • Great for Docker Swarm clusters
  • UI feels a bit dated vs Coolify
docker run -p 80:80 -p 443:443 -p 3000:3000 -v /var/run/docker.sock:/var/run/docker.sock -v /captain:/captain caprover/caprover

Dokku

Minimalist

The original mini-Heroku. 100% CLI-driven, git-push-to-deploy. Perfect if you like terminals and want zero extra services running.

  • Free & open-source (MIT)
  • Lightest footprint (<100 MB RAM)
  • Buildpack + Dockerfile + Docker image support
  • Plugin ecosystem (MySQL, Redis, S3 backup…)
  • No web UI in core (paid add-on exists)
wget -NP . https://dokku.com/install/v0.35.9/bootstrap.sh && sudo DOKKU_TAG=v0.35.9 bash bootstrap.sh

Portainer CE

Container GUI

Not a deployer per se — it's a Docker management GUI. If you want to keep writing docker-compose.yml by hand but need a pane-of-glass to start/stop/log containers, this is it.

  • Free Community Edition
  • Web UI for every Docker object
  • Multi-host dashboard
  • Role-based access control
  • Doesn't build/deploy apps — just manages them
docker run -d -p 9443:9443 --name portainer --restart=always -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data portainer/portainer-ce:latest
TL;DR pick: Coolify if you want a modern Git-deploy UI CapRover if you might scale to a cluster later Dokku if you prefer terminal-only and minimum overhead Portainer if you're keeping raw Compose and just want observability
/opt/sites/docker-compose.yml — host N sites (raw-Compose alternative)
services:

  site1:
    image: nginx:1.27-alpine
    container_name: site1
    restart: unless-stopped
    ports:
      - "8001:80"                  # HAProxy backend target
    volumes:
      - "./site1/html:/usr/share/nginx/html:ro"
    read_only: true
    tmpfs:
      - /var/cache/nginx
      - /var/run
      - /tmp
    security_opt: [no-new-privileges:true]
    cap_drop: [ALL]
    cap_add: [CHOWN, SETGID, SETUID, NET_BIND_SERVICE]
    logging: { driver: json-file, options: { max-size: 10m, max-file: "3" } }

  site2:
    image: wordpress:php8.2-apache
    container_name: site2
    restart: unless-stopped
    ports: ["8002:80"]
    environment:
      WORDPRESS_DB_HOST: site2_db
      WORDPRESS_DB_USER: wp
      WORDPRESS_DB_PASSWORD: "strong-pw"
      WORDPRESS_DB_NAME: wp
    volumes: ["./site2/wp-content:/var/www/html/wp-content"]
    depends_on: [site2_db]

  site2_db:
    image: mariadb:11
    container_name: site2_db
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: wp
      MARIADB_USER: wp
      MARIADB_PASSWORD: "strong-pw"
      MARIADB_ROOT_PASSWORD: "strong-root-pw"
    volumes: ["./site2/db:/var/lib/mysql"]

  site3:
    image: node:22-alpine
    container_name: site3
    restart: unless-stopped
    ports: ["8003:3000"]
    working_dir: /app
    volumes: ["./site3:/app"]
    command: ["node", "server.js"]
Bring up the whole stack cd /opt/sites && docker compose up -d — Docker pulls images, starts containers. Each site is now reachable from pfSense at 192.168.10.20:800X. Hit those ports from HAProxy backends (Step 5).
Step 07

Point the domains at your public IP

At your domain registrar (or Cloudflare if you use their DNS), create A records for each site pointing to your public IP from Step 3.

DNS records for 3 sites
# At your DNS host (GoDaddy / Cloudflare / registrar):

Type  Name               Value              TTL
A     site1.yourdomain   203.0.113.22       Auto
A     site2.yourdomain   203.0.113.22       Auto
A     site3.yourdomain   203.0.113.22       Auto
A     @                  203.0.113.22       Auto   # bare root domain
A     www                203.0.113.22       Auto   # www alias
If your public IP is dynamic A records break when the IP changes. Fix: use pfSense DynamicDNS (Services › Dynamic DNS) with Cloudflare / No-IP / DuckDNS. pfSense will update the A record automatically whenever the WAN IP changes. For production, though, insist on a static IP from the ISP.
Step 08

Harden everything — pfSense, host, containers

You're now publicly reachable on port 443. That's a target. Three layers of defence:

Layer 1 — pfSense itself

Follow the Day-1 hardening playbook — change admin password, HTTPS-only WebGUI on custom port, restrict admin to management VLAN, 2FA, Suricata IDS on WAN, AutoConfigBackup.

Layer 2 — Docker host OS

harden-ubuntu.sh
# SSH: key-only, custom port, disable root login
sudo sed -i 's/^#\?Port .*/Port 52022/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?PermitRootLogin .*/PermitRootLogin no/' /etc/ssh/sshd_config
sudo sed -i 's/^#\?PasswordAuthentication .*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo systemctl restart sshd

# Host firewall — allow only 22 and container ports from LAN
sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 192.168.10.0/24 to any port 52022 proto tcp   # SSH from LAN only
sudo ufw allow from 192.168.10.1 to any port 8001:8099 proto tcp  # pfSense -> containers
sudo ufw enable

# Fail2ban — block SSH brute forcers
sudo apt install -y fail2ban
sudo systemctl enable --now fail2ban

# Automatic security updates
sudo dpkg-reconfigure --priority=low unattended-upgrades

# Disable services you don't need
sudo systemctl disable --now snapd cups bluetooth avahi-daemon 2>/dev/null || true

Layer 3 — Container security

The most commonly skipped step Test backups by actually restoring one. 70% of self-hosted outages we see in India are backups-that-didn't-work-when-needed. Once every 30 days, spin up a fresh VM, restore the latest backup, verify the sites come up. If you skip this, you don't have backups — you have wishful thinking.
Want us to build this for you?

Full self-hosting stack, turnkey delivery

ISP planning + modem reconfiguration + pfSense install + HAProxy + Let's Encrypt + Docker host + first 3 sites migrated + hardening + documentation. Typical turnaround: 3 working days.

✓ Copied