A Dark Server is a VPS architecture predicated on a single principle: eliminate every inbound attack surface at the network layer. Rather than relying on a firewall to filter inbound traffic, the system exposes no listening ports to the public internet whatsoever. All external access is mediated through an outbound-only tunnel to a Zero Trust edge network. This approach is a practical application of operational security principles — specifically, minimizing exposure to reduce the adversary’s available attack surface.
This guide covers the full implementation stack: kernel hardening, service isolation, tunnel configuration, and verification.
What You’ll End Up With
- A server with no open ports on any public interface
- SSH access routed through Cloudflare’s identity layer
- No root login, no password authentication
- Your real server IP never appearing in DNS
- Automatic tunnel recovery on
cloudflaredfailure
Threat Model
This architecture addresses a specific set of threats. Know what it covers and what it does not.
| Threat | Mitigation |
|---|---|
| Port scanning / service discovery | No open ports — the server is silent to all scanners |
| Brute force SSH | SSH is not reachable from the public internet |
| IP attribution via DNS | Real IP never appears in any DNS record |
| Credential stuffing | Key-only auth plus Cloudflare Access identity gate |
| Opportunistic exploitation | Zero publicly exposed attack surface |
What this does NOT protect against:
- Cloudflare knows your server’s IP — the tunnel originates from it
- Your VPS provider knows your server’s IP — they own the hardware
- Your IP still exists and is scannable; it returns nothing, but is attributable to you via provider billing records
- Outbound connections reveal your IP to every destination your server reaches
This is a low attack surface architecture, not an anonymity architecture. For CTI infrastructure, personal tooling, and self-hosted services, this is an acceptable and well-established tradeoff.
Prerequisites
Before proceeding, confirm the following:
- A fresh Ubuntu 24.04 VPS with root or sudo access.
- A domain managed on Cloudflare (free tier is sufficient).
- Your VPS provider’s out-of-band console (VNC or serial) is accessible and you have confirmed you can log in through it. This is your recovery path — do not skip this step.
cloudflaredwill be installed during Phase 3. No other dependencies are required beyond what ships with a standard base install.
Architecture Overview
The following diagram illustrates the complete traffic flow from an end user to a service running on the dark server.
flowchart LR A["User / Client"] -->|"HTTPS / SSH"| B["Cloudflare Edge"] B -->|"Authenticated Origin Pull (mTLS)"| C["Cloudflare Tunnel<br/>(outbound-only)"] C -->|"localhost only"| D["Nginx (127.0.0.1:443)"] D -->|"proxy_pass"| E["Application Container<br/>(127.0.0.1:8080)"] F["Public Internet<br/>Scanners / Threat Actors"] -->|"No route to host"| G["Blocked — No open ports"]
The public internet has no route to any service. The only egress from the VPS is the tunnel connection initiated by the cloudflared daemon.
Phase 0: Server Provisioning
This phase covers creating the VPS before any hardening begins. Using Hetzner Cloud as the reference provider; steps are equivalent on DigitalOcean, Vultr, or similar.
0.1 Create the Server
In the Hetzner console, create a new server with the following settings:
- Location: Helsinki or any EU region — better data sovereignty posture than US-based locations
- OS: Ubuntu 24.04
- Type: Shared vCPU is sufficient for most use cases
Networking:
- Enable Public IPv4 — required temporarily for bootstrapping; assess removal after tunnel is established
- Disable Public IPv6 — unnecessary attack surface
- Do not attach a private network unless you have an existing NAT gateway providing outbound internet access
Use a non-descriptive server name. Avoid names that reveal purpose or infrastructure type (e.g., not cti-server, ssh-box, vpn-node).
0.2 Bootstrap Firewall
Create a dedicated firewall. Do not reuse existing firewalls — scope is everything here.
| Direction | Protocol | Port | Source | Purpose |
|---|---|---|---|---|
| Inbound | TCP | 22 | Your IP only | Bootstrap SSH access |
| Inbound | All | All | Anywhere | Deny (default) |
| Outbound | All | All | Anywhere | Allow |
Get your current IP: curl ifconfig.me
0.3 Initial SSH and User Setup
SSH in as root using the public IP:
ssh root@YOUR_PUBLIC_IPUpdate the system, then create a non-root user:
apt update && apt upgrade -y
adduser youruser
usermod -aG sudo youruser
rsync --archive --chown=youruser:youruser ~/.ssh /home/youruserHarden the SSH daemon:
nano /etc/ssh/sshd_configSet:
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
systemctl restart sshdVerify your non-root user works before continuing. Open a new terminal:
ssh youruser@YOUR_PUBLIC_IPDo not proceed until this succeeds. If you lock yourself out, use the provider’s out-of-band console (see Section 1.1).
Add the bootstrap UFW rule:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from YOUR_HOME_IP to any port 22
sudo ufw enablePhase 1: Hardened Foundation
1.1 Emergency Console Access and Rollback Plan
Before closing any network ports, verify that your VPS provider’s out-of-band console (VNC or serial) is accessible. Providers such as Hetzner, DigitalOcean, and Linode all offer a web-based console.
Rollback procedure — if the tunnel fails and SSH becomes unreachable:
- Log in via the provider’s VNC/serial console.
- Check the
cloudflaredservice status:sudo systemctl status cloudflared - If the tunnel daemon has crashed, restart it:
sudo systemctl restart cloudflared - If the tunnel is healthy but Access policy is misconfigured, temporarily re-enable public SSH to recover:
sudo ufw allow in 22/tcp && sudo systemctl edit ssh.serviceand remove theListenAddressoverride. - Once access is restored, diagnose and re-apply hardening steps one at a time.
Keep the console URL bookmarked. It is your last resort.
1.2 Kernel Hardening via sysctl
Create /etc/sysctl.d/99-dark-server.conf with the following parameters. Each directive is annotated with its purpose.
# --- IP Spoofing and Source Routing ---
# rp_filter: Reverse path filtering. The kernel drops packets whose source
# address is not reachable via the interface the packet arrived on.
# This defeats IP spoofing attacks.
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Disable processing of IP source-routed packets.
net.ipv4.conf.all.accept_source_route = 0
# --- ICMP Redirect Protection ---
# Prevents an attacker from redirecting traffic via forged ICMP messages,
# a classic man-in-the-middle precursor technique.
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.all.secure_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
# --- TCP Stack Hardening ---
# SYN cookies mitigate SYN flood denial-of-service attacks.
net.ipv4.tcp_syncookies = 1
# RFC 1337 fix: Prevents TIME_WAIT assassination attacks.
net.ipv4.tcp_rfc1337 = 1
# Disable TCP timestamps to reduce information leakage (system uptime).
# Trade-off: disabling timestamps also disables PAWS (Protection Against
# Wrapped Sequence numbers), which can degrade throughput on high-latency
# or high-bandwidth links. Accept this trade-off only if uptime concealment
# is a requirement for your threat model.
net.ipv4.tcp_timestamps = 0
# --- Memory and Address Space Hardening ---
# Full ASLR: randomize all memory mappings (stack, VDSO, shared libraries).
kernel.randomize_va_space = 2
# Restrict kernel pointer exposure in /proc and other interfaces.
# This is a pre-exploitation information-leak mitigation: it prevents an
# attacker from resolving kernel symbol addresses via /proc/kallsyms or
# similar interfaces, which is a prerequisite step for many kernel exploits
# even before any privilege escalation has occurred.
kernel.kptr_restrict = 2Apply immediately and persist across reboots:
sudo sysctl -p /etc/sysctl.d/99-dark-server.conf1.3 Eliminating Leak Vectors
IPv6 Disable
Most VPS providers assign a public IPv6 address. If IPv6 is not required, disable it at the provider level first, then reinforce at the OS level by adding the following to your sysctl configuration:
net.ipv6.conf.all.disable_ipv6 = 1SSH Socket Hardening
Modern systemd-managed systems run an ssh.socket unit that listens on all interfaces, bypassing sshd_config bind settings. This must be corrected explicitly.
Create /etc/ssh/sshd_config.d/dark.conf:
ListenAddress 127.0.0.1Then switch from socket activation to direct service management:
sudo systemctl disable --now ssh.socket
sudo systemctl enable --now ssh.serviceSSH is now bound exclusively to the loopback interface and is invisible to any external network scanner.
Phase 2: Service Layer Isolation
2.1 Restricting Docker Port Binding
By default, Docker modifies iptables rules to expose published container ports on all interfaces, circumventing UFW and other firewall tooling. This behavior is corrected by configuring the Docker daemon’s default bind address.
Create or modify /etc/docker/daemon.json:
{
"ip": "127.0.0.1"
}Apply the change:
sudo systemctl restart dockerAll containers launched without an explicit -p <host-ip>:<port>:<port> override will now bind only to 127.0.0.1.
UFW and Docker interaction: Docker manages its own
iptableschains and inserts rules with higher priority than UFW’s chains. This means UFWINPUTrules do not filter Docker-published ports by default. The"ip": "127.0.0.1"daemon setting is the correct fix for this architecture. If you later add UFW egress rules (Phase 4), be aware that Docker container traffic exits via theDOCKER-USERchain — add any container-specific egress restrictions there rather than in UFW’sOUTPUTchain.
2.2 Nginx with Authenticated Origin Pulls (mTLS)
Cloudflare’s Authenticated Origin Pull feature ensures that Nginx will only accept TLS connections presenting a certificate signed by the Cloudflare CA. Any direct connection attempt — including from a scanner that somehow discovers the origin IP — will be rejected at the TLS layer.
Step 1: Obtain a TLS certificate via DNS-01 challenge.
Because Nginx is bound to 127.0.0.1, Certbot’s standard HTTP-01 challenge cannot reach the server from the public internet. Use the DNS-01 challenge instead, which validates ownership via a DNS TXT record rather than an inbound HTTP request. With Cloudflare DNS:
sudo apt install certbot python3-certbot-dns-cloudflare
# Create a credentials file with your Cloudflare API token
# (scoped to Zone:DNS:Edit for your domain only)
sudo mkdir -p /etc/letsencrypt
sudo tee /etc/letsencrypt/cloudflare.ini > /dev/null <<EOF
dns_cloudflare_api_token = YOUR_CF_API_TOKEN
EOF
sudo chmod 600 /etc/letsencrypt/cloudflare.ini
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d vault.yourdomain.comStep 2: Download the Cloudflare Origin Pull CA certificate.
sudo mkdir -p /etc/nginx/certs
sudo wget https://developers.cloudflare.com/ssl/static/authenticated_origin_pull_ca.pem \
-O /etc/nginx/certs/cf-aop.pemStep 3: Configure Nginx.
Note the use of 127.0.0.1:8080 in proxy_pass rather than localhost:8080. On systems where localhost resolves to ::1 (IPv6 loopback) before 127.0.0.1, using the hostname can cause connection failures if the application is not listening on the IPv6 interface. Always use the explicit address.
server {
# Bind exclusively to loopback — never a public interface.
listen 127.0.0.1:443 ssl;
server_name vault.yourdomain.com;
# TLS certificate (obtained via DNS-01 challenge above).
ssl_certificate /etc/letsencrypt/live/vault.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/vault.yourdomain.com/privkey.pem;
# mTLS: Require a client certificate signed by the Cloudflare CA.
# Requests from any other source are rejected with a 400 error.
ssl_client_certificate /etc/nginx/certs/cf-aop.pem;
ssl_verify_client on;
# Suppress server version information from response headers.
server_tokens off;
# Redirect error pages to a non-descriptive custom page.
error_page 403 404 /custom_error.html;
# Defensive security headers.
add_header X-Frame-Options "DENY";
add_header X-Content-Type-Options "nosniff";
add_header Content-Security-Policy "default-src 'self';";
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
}
}Phase 3: The Ghost Bridge — Cloudflare Tunnel
The Cloudflare Tunnel (cloudflared) daemon establishes a persistent, outbound-only connection from the VPS to the Cloudflare edge. No inbound port is opened; the edge proxies traffic into the tunnel over this connection.
3.1 Installing and Configuring cloudflared
Install the daemon:
curl -L --output cloudflared.deb \
https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.debAuthenticate and create the tunnel:
# Opens a browser link — authenticate with your Cloudflare account
cloudflared tunnel login
# Create a named tunnel (use an opaque name, not "ssh" or "web")
cloudflared tunnel create srv-01-ghostThis creates a credentials file at ~/.cloudflared/<TUNNEL-UUID>.json.
Configure the tunnel ingress at ~/.cloudflared/config.yml:
tunnel: <TUNNEL-UUID>
credentials-file: /root/.cloudflared/<TUNNEL-UUID>.json
ingress:
# HTTPS traffic to your application
- hostname: vault.yourdomain.com
service: https://127.0.0.1:443
originRequest:
noTLSVerify: false
# SSH access (see Section 3.2)
- hostname: srv-01-ghost.yourdomain.com
service: ssh://127.0.0.1:22
# Required catch-all rule
- service: http_status:404Route DNS and install as a system service:
# Create CNAME records in Cloudflare DNS pointing to the tunnel
cloudflared tunnel route dns srv-01-ghost vault.yourdomain.com
cloudflared tunnel route dns srv-01-ghost srv-01-ghost.yourdomain.com
# Install as a systemd service
sudo cloudflared service install
sudo systemctl enable --now cloudflaredConfigure automatic restart on failure. If cloudflared crashes and the service does not restart, you are locked out until you reach the provider console:
sudo systemctl edit cloudflaredAdd:
[Service]
Restart=always
RestartSec=5Save and reload:
sudo systemctl daemon-reload
sudo systemctl restart cloudflared3.2 Hostname Obfuscation
Avoid predictable subdomain naming conventions (e.g., ssh.domain.com, admin.domain.com). Automated scanners and certificate transparency log monitors actively enumerate such patterns. Use operationally opaque names (e.g., srv-01-ghost.domain.com) and maintain a private mapping document.
This is a lightweight application of OPSEC counter-intelligence principles — denying the adversary enumerable, predictable infrastructure naming.
3.3 SSH Access via Tunnel
With SSH bound to 127.0.0.1 and no public port open, access is routed through Cloudflare Access. Configure your local workstation’s SSH client as follows.
File: ~/.ssh/config
Host dark-vps
ProxyCommand /usr/local/bin/cloudflared access ssh --hostname srv-01-ghost.domain.comThe cloudflared access ssh subcommand handles authentication against the Cloudflare Access policy (including MFA enforcement) before establishing the SSH proxy channel. Port 22 remains completely closed to the public internet.
Phase 3.5: Going Dark — The Cutover
This is the point of no return. Before removing bootstrap access, confirm the tunnel path is fully operational.
Confirm Tunnel SSH Works First
Open a second terminal and verify:
ssh dark-vps
# Should authenticate through Cloudflare Access and open a shellDo not proceed until you have an active tunnel session in hand.
Remove the Bootstrap Firewall Rule
sudo ufw delete allow from YOUR_HOME_IP to any port 22
sudo ufw reloadVerify the Server is Dark
On the server:
ss -tlnp | grep ':22'
# Should show 127.0.0.1:22 only — nothing on 0.0.0.0From your local machine:
# Tunnel access should still work
ssh dark-vps
# Direct SSH should be dead
ssh youruser@YOUR_PUBLIC_IP
# Should time out — no responseCritical: Do not stop or disable
sshd. Thecloudflareddaemon proxies connections to sshd on localhost. The architecture isyour machine → Cloudflare Access → cloudflared → sshd on [::1]:22. The server is dark because the firewall rule is gone and SSH is bound to loopback — not because sshd is stopped.
Attacker Perspective
With the server fully dark, this is what an adversary sees:
| Attacker Action | Result |
|---|---|
DNS lookup ssh.yourdomain.com | Returns a Cloudflare IP — not your server |
| Port scan your public IP | All ports filtered, no services detected |
| Attempt direct SSH to IP | Connection times out |
| Attempt to reach tunnel hostname | Cloudflare Access identity wall |
| Brute force any service | No services are exposed to attempt |
Phase 4: Advanced Defense Layers
4.1 Egress Filtering
This is among the most effective post-compromise containment controls available. If an attacker achieves code execution on your application layer, their next action is almost universally to establish outbound command-and-control — a behavior categorized under MITRE ATT&CK as Command and Control (TA0011). Egress filtering disrupts the callback phase.
This is directly relevant to what threat intelligence identifies as a primary adversary objective following initial access: establishing persistent C2 channels.
# Deny all outbound traffic by default (supersedes the allow-outgoing default set in Phase 0)
sudo ufw default deny outgoing
# Permit only required egress paths
sudo ufw allow out to any port 53 # DNS resolution
sudo ufw allow out to any port 80 # Package repository access (HTTP)
sudo ufw allow out to any port 443 # Package repository access (HTTPS) and Cloudflare Tunnel
sudo ufw allow out to <Cloudflare-IP-Ranges> # Explicit tunnel egressCloudflare publishes its IP ranges at https://www.cloudflare.com/ips/. These should be enumerated and added as explicit allow rules.
Docker egress note: UFW
OUTPUTrules do not apply to traffic originating from Docker containers. Container egress is governed by theDOCKER-USERiptables chain. To restrict container outbound traffic, add rules directly:sudo iptables -I DOCKER-USER -j DROPas a default deny, then add specificACCEPTrules before it. Consider using a tool likeufw-dockerto manage this more conveniently.
4.2 Double-Dark: VPN-in-Tunnel Architecture
For nodes requiring the highest assurance, a second cryptographic layer can be inserted behind the tunnel. Deploy Tailscale or a self-managed WireGuard mesh inside the VPS, then configure the Cloudflare Tunnel to target the VPN’s internal IP address (e.g., 100.x.x.x for Tailscale).
The resulting access chain is:
Cloudflare Access MFA → Cloudflare Tunnel → WireGuard/Tailscale Auth → Service
An attacker must independently compromise three distinct authentication and cryptographic systems before reaching a service.
Example config.yml ingress targeting a Tailscale IP:
ingress:
- hostname: vault.yourdomain.com
service: https://100.64.0.2:443
originRequest:
noTLSVerify: false
- hostname: srv-01-ghost.yourdomain.com
service: ssh://100.64.0.2:22
- service: http_status:404In this configuration, the application containers are accessible only via the Tailscale mesh IP. Even if the cloudflared process were compromised, the attacker would still need valid Tailscale credentials to reach any service.
This architecture is appropriate for high-value infrastructure such as the kind described in self-hosted intelligence platforms.
Phase 5: Maintenance and Observability
5.1 Persistent Local Logging
Avoid transmitting logs to external SaaS platforms over uncontrolled channels. Configure journald for persistent local storage.
Edit /etc/systemd/journald.conf:
[Journal]
Storage=persistentFor structured metrics and log aggregation, deploy a Grafana/Loki stack in Docker containers and expose it via a dedicated Cloudflare Tunnel hostname protected by Cloudflare Access with MFA enforcement. This ensures your observability plane is subject to the same Zero Trust controls as every other service.
This self-contained logging approach aligns with the isolated lab architecture philosophy — maintaining operational capability without external dependencies.
5.2 Unattended Security Updates
Because the server retains outbound access on port 443, automated patching is viable. Enable unattended-upgrades to ensure kernel and security library patches are applied without requiring manual intervention:
sudo apt install unattended-upgrades
sudo dpkg-reconfigure --priority=low unattended-upgrades5.3 Ongoing OPSEC
A dark network perimeter does not mean the server is invisible at the application layer. Every outbound connection your server initiates reveals its real IP to the destination.
Monitor active outbound connections:
sudo ss -tnp | grep ESTABLISHEDAnything connecting to external IPs is a potential privacy leak. Common offenders:
- Cloud IDEs (VS Code Server, GitHub Codespaces, Project IDX) — phone home to vendor infrastructure on startup
- Snap packages — contact Canonical servers on every refresh cycle
- Automatic update checkers — various packages poll upstream endpoints on a schedule
Audit running services periodically:
systemctl list-units --type=service --state=runningDisable anything you do not recognize or need. Each running service is an additional exposure vector if it makes outbound connections or processes untrusted input.
MITRE ATT&CK Alignment
The controls implemented in this guide directly mitigate or counter the following adversary techniques:
| Control | ATT&CK Technique | Tactic |
|---|---|---|
| No public inbound ports | T1046 — Network Service Discovery | Reconnaissance (TA0043) |
| Egress filtering | T1071 — Application Layer Protocol (C2) | Command and Control (TA0011) |
| mTLS Authenticated Origin Pulls | T1557 — Adversary-in-the-Middle | Collection (TA0009) |
kptr_restrict / ASLR | T1068 — Exploitation for Privilege Escalation | Privilege Escalation (TA0004) |
| SSH via tunnel (no public port) | T1133 — External Remote Services | Initial Access (TA0001) |
| Egress filtering (callback block) | T1105 — Ingress Tool Transfer | Command and Control (TA0011) |
| Hostname obfuscation | T1590 — Gather Victim Network Information | Reconnaissance (TA0043) |
Verification Checklist
The following eight checks define the operational acceptance criteria for a Dark Server deployment. All checks must pass before the server is considered hardened.
| # | Check | Command / Method |
|---|---|---|
| 1 | No public ports | nmap -Pn <Public-IP> returns “All ports filtered” |
| 2 | Loopback binding only | ss -tuln shows only 127.0.0.1 or ::1 for all listeners |
| 3 | SSH service, not socket | systemctl is-active ssh.socket returns inactive |
| 4 | mTLS enforced | Direct connection without Cloudflare cert returns HTTP 400: curl -k https://127.0.0.1:443 -v — look for SSL routines:tls_process_client_certificate:certificate verify failed or an HTTP 400 response body |
| 5 | Docker bound to loopback | /etc/docker/daemon.json contains "ip": "127.0.0.1" |
| 6 | Zero Trust policy active | Cloudflare Access is enforcing MFA/SSO on all configured hostnames |
| 7 | Console access verified | Successful login confirmed via the VPS provider’s VNC/serial console |
| 8 | Kernel parameters persistent | sysctl net.ipv4.tcp_syncookies returns 1 after a reboot |
Limitations and Honest Tradeoffs
This setup is strong but not perfect. These are the explicit tradeoffs you are accepting:
- Cloudflare dependency — If Cloudflare’s network is unavailable, you are locked out. The provider’s out-of-band console is your fallback; keep the URL bookmarked.
- Cloudflare visibility — Cloudflare can see your tunnel traffic metadata and knows your server’s real IP. If your threat model includes Cloudflare as an adversary, this architecture is not appropriate.
- IP still exists — Your public IP is routable and will appear in passive scanning datasets (Shodan, Censys) as a host with no open ports. It is still attributable to you via your VPS provider’s billing records.
- Not anonymous — This is a low attack surface architecture, not an anonymity architecture. Your server’s IP is known to Cloudflare and to your VPS provider. There is no technical mechanism preventing either from disclosing it.
For the intended use cases — CTI infrastructure, personal tooling, self-hosted services — this is an acceptable and practical tradeoff. It eliminates the majority of opportunistic attack vectors while remaining operationally manageable.
