πŸšͺ Three Sites, Three SSH Doors, One Public IP β€” Without Exposing Proxmox

Several websites and several servers, all behind the one internet address you already have β€” the main machine stays hidden, and you keep a way back in if a firewall change locks you out.

:wrapped_gift: What you are actually building

Your provider gives you one address. You want to run more than one thing behind it.

   the internet sees ONE address
              β”‚
        🧱 OPNsense ─── free firewall. Decides what gets in,
              β”‚         and which machine it goes to.
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
 🌐 LIVE    πŸ§ͺ DEV     πŸ” ALT
 the real   the copy   the spare that
 site       you break  takes over
 ssh :2201  ssh :2202  ssh :2203  ← one door number per machine

Proxmox β€” the box that runs all three as software β€” is never visible from the internet. Only the firewall is.

:door: The two traps this saves you from

  • Locking yourself out. One wrong firewall rule, made from the internet side, and your own server is gone with no undo. Everyone does it once. Section 8 makes you set up the way back in before you touch a rule.
  • SSH will not listen to names. Three websites can share one address because the browser says which site it wants. SSH (your terminal login) cannot say that β€” so it gets a door number instead. Section 5 ranks four ways to do it, best to worst.

Every step has a verified backup before it and a written rollback after it.

:world_map: The 9 sections, and where you are standing in each

 β”Œ PLAN ──────────────────────────────────────────────────────┐
 β”‚  1  πŸ“  design rules                                       β”‚
 β”‚  2  πŸ“  source-of-truth worksheet β€” CTID Β· host Β· IP       β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 β”Œ BACKUP ────────────────────────────────────────────────────┐
 β”‚  3  πŸ—„οΈ Proxmox shell β€” vzdump + zstd -t + sha256           β”‚
 β”‚     🧱 OPNsense UI  β€” export config, checksum offline      β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 β”Œ ADDRESS ───────────────────────────────────────────────────┐
 β”‚  4  πŸ—„οΈ + πŸ“¦ inside container β€” verify IP at every layer    β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 β”Œ PUBLISH ───────────────────────────────────────────────────┐
 β”‚  5  🧱 OPNsense UI β€” aliases β†’ NAT β†’ WAN rule β†’ apply ONCE β”‚
 β”‚  6  πŸ“¦ Nginx container β€” vhost, nginx -t before reload     β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 β”Œ PROVE ─────────────────────────────────────────────────────┐
 β”‚  7  πŸ—„οΈ private nc  +  🌍 from OUTSIDE the LAN β€” curl, TLS   β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
 β”Œ RECOVER ───────────────────────────────────────────────────┐
 β”‚  8  πŸ”‘ the way back in, decided before you need it         β”‚
 β”‚  9  βœ… final proof, 14 lines                                β”‚
 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Two different things are both called an alias below: an OPNsense alias (a name for an IP, used in firewall rules) and an SSH alias (a shortcut in ~/.ssh/config). They never touch each other.

Every CTID, IP and hostname is a placeholder. Fill the worksheet in section 2 first.


πŸ“ 1. What we are building
Internet
   |
Public IP / firewall
   |
OPNsense VM
   |
   `-- Private LAN / management bridge
          |-- Nginx LIVE container
          |-- Nginx DEV container
          `-- Nginx ALT container

Core design rules:

  • Keep the Proxmox management plane private whenever practical.
  • Publish services through OPNsense instead of exposing the hypervisor.
  • Give every container a documented static private address.
  • Keep CTIDs, hostnames, and final IP octets aligned when practical.
  • Publish only required ports.
  • Back up before making network changes.
  • Validate configuration syntax before reloads.
  • Keep a console path that does not depend on the network you are changing.
  • Never assume that a remembered topology is still correct; verify it live.
πŸ“ 2. Build a source-of-truth worksheet first
ROLE       CTID     HOSTNAME          PRIVATE IP          BRIDGE
LIVE       <110>    <nginx-live>      <10.20.30.110/24>  <vmbr-lan>
DEV        <120>    <nginx-dev>       <10.20.30.120/24>  <vmbr-lan>
ALT        <130>    <nginx-alt>       <10.20.30.130/24>  <vmbr-lan>

LAN gateway:       <10.20.30.1>
DNS resolver:      <10.20.30.1>
Public hostname:   <example.net>
DEV hostname:      <dev.example.net>
ALT hostname:      <alt.example.net>

Do not proceed until Proxmox configuration, guest configuration, OPNsense aliases, NAT rules, DNS, and documentation agree.

πŸ—„οΈπŸ§± 3. Capture the baseline and back up

Run read-only discovery on the Proxmox host:

hostname
pveversion
pct list
qm list
pvesm status
cat /etc/pve/storage.cfg
ip -br address
ip route
bridge link
df -h

for id in <LIVE_CTID> <DEV_CTID> <ALT_CTID>; do
  pct config "$id"
  pct listsnapshot "$id"
done

Before changing a container, create a verified backup using your established backup system.

Cold backup example:

vzdump <CTID> --mode stop --storage <BACKUP_STORAGE> --compress zstd

Do not stop at β€œthe file exists.” Verify it:

test -s <BACKUP_ARCHIVE>
zstd -t <BACKUP_ARCHIVE>
zstdcat <BACKUP_ARCHIVE> | tar -tf - >/dev/null

md5sum <BACKUP_ARCHIVE> > <BACKUP_ARCHIVE>.md5
sha256sum <BACKUP_ARCHIVE> > <BACKUP_ARCHIVE>.sha256

md5sum -c <BACKUP_ARCHIVE>.md5
sha256sum -c <BACKUP_ARCHIVE>.sha256

Export the OPNsense configuration before changing aliases, NAT, or firewall policy:

System -> Configuration -> Backups

Then checksum the exported file offline:

sha256sum <OPNSENSE-CONFIG-BACKUP.xml>

Do not publish the OPNsense XML. It may contain sensitive network and credential material.

πŸ—„οΈπŸ“¦ 4. Verify addressing at every layer

From Proxmox:

pct config <CTID>
pct exec <CTID> -- ip -br address
pct exec <CTID> -- ip route
pct exec <CTID> -- cat /etc/hosts

Search documentation and configuration for stale addresses:

rg -n '<OLD_PRIVATE_IP>|<EXPECTED_PRIVATE_IP>' <DOCUMENTATION_DIRECTORY>

Classify every match. Never perform a blind global replacement.

A failed ping does not prove an address is unused. Check DHCP leases, ARP/neighbour state, firewall aliases, DNS, documentation, and the intended addressing plan.

Before changing a guest network file:

cp -a <NETWORK_CONFIG> <NETWORK_CONFIG>.before-$(date +%Y%m%d-%H%M%S)

Use the Proxmox console during network-address changes. Verify locally before touching NAT:

ip -br address
ip route
ping -c 3 <LAN_GATEWAY>
🧱 5. OPNsense aliases, NAT, and firewall order

Use aliases instead of scattering literal private IPs across rules:

Alias: NGINX_LIVE  -> <LIVE_PRIVATE_IP>
Alias: NGINX_DEV   -> <DEV_PRIVATE_IP>
Alias: NGINX_ALT   -> <ALT_PRIVATE_IP>

For each published service:

  1. Back up OPNsense.
  2. Update or create the alias.
  3. Create the port forward.
  4. Create the associated WAN pass rule.
  5. Apply once.
  6. Test from an actual external network.
  7. Roll back immediately if management or LIVE service becomes unhealthy.

Typical mappings:

WAN TCP/80  -> selected Nginx TCP/80
WAN TCP/443 -> selected Nginx TCP/443

If several websites share one public IP, normally forward 80/443 to one reverse proxy and route by HTTP Host / TLS SNI there.

SSH is different

SSH is raw TCP. It does not provide HTTP Host routing.

If multiple DNS names resolve to the same public IP, a firewall cannot choose a different TCP/22 backend based on the typed hostname.

Use one of these patterns:

Best:       VPN or hardened SSH jump host
Good:       separate public IP per SSH endpoint
Acceptable: unique external ports, such as 2201/2202/2203
Avoid:      broadly exposed password-enabled root SSH

Client aliases:

Host live-admin
    HostName <PUBLIC_IP_OR_NAME>
    Port <LIVE_EXTERNAL_SSH_PORT>
    User <ADMIN_USER>

Host dev-admin
    HostName <PUBLIC_IP_OR_NAME>
    Port <DEV_EXTERNAL_SSH_PORT>
    User <ADMIN_USER>

Host alt-admin
    HostName <PUBLIC_IP_OR_NAME>
    Port <ALT_EXTERNAL_SSH_PORT>
    User <ADMIN_USER>

Prefer keys, source restrictions, rate limiting, and private/VPN management.

πŸ“¦ 6. Nginx virtual hosts and safe reloads

Preserve a known-good configuration:

cp -a /etc/nginx /root/nginx-backup-$(date +%Y%m%d-%H%M%S)
nginx -T > /root/nginx-before-$(date +%Y%m%d-%H%M%S).txt

Minimal HTTP virtual host:

server {
    listen 80;
    listen [::]:80;
    server_name <HOSTNAME>;

    root /var/www/<SITE>;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

Validate before every reload:

nginx -t
systemctl reload nginx
systemctl --no-pager --full status nginx
ss -lntp | grep -E ':(80|443)\b'

Obtain certificates only after DNS and the selected ACME challenge path are working. Never copy private keys into documentation.

πŸ—„οΈπŸŒ 7. Test private and public paths independently

Private tests:

for ip in <LIVE_PRIVATE_IP> <DEV_PRIVATE_IP> <ALT_PRIVATE_IP>; do
  for port in 22 80 443; do
    nc -vz -w 5 "$ip" "$port"
  done
done

External DNS and service tests:

for host in <LIVE_HOST> <DEV_HOST> <ALT_HOST>; do
  echo "=== $host ==="
  getent ahostsv4 "$host"
  curl -sSIL --max-time 10 "https://$host/"
done

TLS inspection:

openssl s_client \
  -connect <HOSTNAME>:443 \
  -servername <HOSTNAME> </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates -ext subjectAltName

Always test from a network that is genuinely outside the private LAN.

πŸ”‘ 8. Management-plane recovery

Before risky edge changes, know how you will recover if SSH disappears.

Keep:

  • Proxmox console access.
  • OPNsense console access.
  • A current OPNsense configuration backup.
  • A known-good Nginx configuration.
  • A written rollback sequence.
  • The exact management IP/route needed to get back in.

If a firewall or NAT change unexpectedly breaks access, stop making additional changes. Recover from the console or restore the known-good configuration first.

βœ… 9. Final proof checklist

Do not call the work complete until you can prove:

[ ] Proxmox management still reachable
[ ] OPNsense healthy
[ ] LIVE private path works
[ ] DEV private path works
[ ] ALT private path works
[ ] Public DNS correct
[ ] Public HTTP/HTTPS correct
[ ] TLS certificate correct
[ ] Nginx configuration passes nginx -t
[ ] Expected listeners present
[ ] Backups verified
[ ] Rollback path documented
[ ] Documentation updated
[ ] Remaining unknowns recorded

:receipt: BCBC field rule: evidence beats memory

Do not write β€œworking” as a conclusion by itself.

Record:

  • what was tested;
  • when it was tested;
  • what result was observed;
  • where the evidence is stored;
  • what rollback is available; and
  • what remains unknown.

Useful states:

DOCUMENTED     present in an approved record
LIVE-VERIFIED tested against the running system with evidence
INFERRED       plausible but not yet proven
PROPOSED       intended future state
UNKNOWN        insufficient evidence

Never recover operational state from memory when it can be recovered from evidence.

2 Likes