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 cloudflared failure

Threat Model

This architecture addresses a specific set of threats. Know what it covers and what it does not.

ThreatMitigation
Port scanning / service discoveryNo open ports — the server is silent to all scanners
Brute force SSHSSH is not reachable from the public internet
IP attribution via DNSReal IP never appears in any DNS record
Credential stuffingKey-only auth plus Cloudflare Access identity gate
Opportunistic exploitationZero 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.
  • cloudflared will 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.

DirectionProtocolPortSourcePurpose
InboundTCP22Your IP onlyBootstrap SSH access
InboundAllAllAnywhereDeny (default)
OutboundAllAllAnywhereAllow

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_IP

Update 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/youruser

Harden the SSH daemon:

nano /etc/ssh/sshd_config

Set:

PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
systemctl restart sshd

Verify your non-root user works before continuing. Open a new terminal:

ssh youruser@YOUR_PUBLIC_IP

Do 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 enable

Phase 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:

  1. Log in via the provider’s VNC/serial console.
  2. Check the cloudflared service status: sudo systemctl status cloudflared
  3. If the tunnel daemon has crashed, restart it: sudo systemctl restart cloudflared
  4. 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.service and remove the ListenAddress override.
  5. 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 = 2

Apply immediately and persist across reboots:

sudo sysctl -p /etc/sysctl.d/99-dark-server.conf

1.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 = 1

SSH 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.1

Then switch from socket activation to direct service management:

sudo systemctl disable --now ssh.socket
sudo systemctl enable --now ssh.service

SSH 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 docker

All 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 iptables chains and inserts rules with higher priority than UFW’s chains. This means UFW INPUT rules 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 the DOCKER-USER chain — add any container-specific egress restrictions there rather than in UFW’s OUTPUT chain.

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.com

Step 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.pem

Step 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.deb

Authenticate 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-ghost

This 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:404

Route 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 cloudflared

Configure 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 cloudflared

Add:

[Service]
Restart=always
RestartSec=5

Save and reload:

sudo systemctl daemon-reload
sudo systemctl restart cloudflared

3.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.com

The 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 shell

Do 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 reload

Verify the Server is Dark

On the server:

ss -tlnp | grep ':22'
# Should show 127.0.0.1:22 only — nothing on 0.0.0.0

From 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 response

Critical: Do not stop or disable sshd. The cloudflared daemon proxies connections to sshd on localhost. The architecture is your 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 ActionResult
DNS lookup ssh.yourdomain.comReturns a Cloudflare IP — not your server
Port scan your public IPAll ports filtered, no services detected
Attempt direct SSH to IPConnection times out
Attempt to reach tunnel hostnameCloudflare Access identity wall
Brute force any serviceNo 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 egress

Cloudflare publishes its IP ranges at https://www.cloudflare.com/ips/. These should be enumerated and added as explicit allow rules.

Docker egress note: UFW OUTPUT rules do not apply to traffic originating from Docker containers. Container egress is governed by the DOCKER-USER iptables chain. To restrict container outbound traffic, add rules directly: sudo iptables -I DOCKER-USER -j DROP as a default deny, then add specific ACCEPT rules before it. Consider using a tool like ufw-docker to 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:404

In 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=persistent

For 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-upgrades

5.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 ESTABLISHED

Anything 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=running

Disable 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:

ControlATT&CK TechniqueTactic
No public inbound portsT1046 — Network Service DiscoveryReconnaissance (TA0043)
Egress filteringT1071 — Application Layer Protocol (C2)Command and Control (TA0011)
mTLS Authenticated Origin PullsT1557 — Adversary-in-the-MiddleCollection (TA0009)
kptr_restrict / ASLRT1068 — Exploitation for Privilege EscalationPrivilege Escalation (TA0004)
SSH via tunnel (no public port)T1133 — External Remote ServicesInitial Access (TA0001)
Egress filtering (callback block)T1105 — Ingress Tool TransferCommand and Control (TA0011)
Hostname obfuscationT1590 — Gather Victim Network InformationReconnaissance (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.

#CheckCommand / Method
1No public portsnmap -Pn <Public-IP> returns “All ports filtered”
2Loopback binding onlyss -tuln shows only 127.0.0.1 or ::1 for all listeners
3SSH service, not socketsystemctl is-active ssh.socket returns inactive
4mTLS enforcedDirect 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
5Docker bound to loopback/etc/docker/daemon.json contains "ip": "127.0.0.1"
6Zero Trust policy activeCloudflare Access is enforcing MFA/SSO on all configured hostnames
7Console access verifiedSuccessful login confirmed via the VPS provider’s VNC/serial console
8Kernel parameters persistentsysctl 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.