Back to VPS & Infra

Zero-Public-Port Production Behind Tailscale

How to lock down your VPS by running multi-container agent stacks and databases exclusively behind a private Tailscale mesh with zero open public ports.

Zero-Public-Port Production Behind Tailscale
Image credit: labs.zeroshot.studio

Contents

The default Docker compose port trap

When developers deploy their first full-stack application or autonomous agent stack to a Linux VPS, the quickest path to getting things running is copying a boilerplate docker-compose.yml file. You define a Postgres container, a Redis cache, an agent runtime, and a web interface, and you assign standard ports so your containers can communicate:

yaml
services:  database:    image: postgres:16-alpine    ports:      - "5432:5432"    environment:      POSTGRES_PASSWORD: "secret_db_password"  redis:    image: redis:7-alpine    ports:      - "6379:6379"  agent-runtime:    image: node:20-alpine    ports:      - "3000:3000"

The stack starts, your web application connects to the database, and everything appears functional. Most developers assume that because they enabled Ubuntu's Uncomplicated Firewall (UFW) with ufw default deny incoming and ufw allow 80/tcp, their database and cache are safely insulated from the public web.

This assumption is false. Within sixty seconds of launching a cloud VPS with that configuration, your Postgres port (5432) and Redis port (6379) are open to the entire internet. Automated vulnerability scanners continuously probe public IPv4 ranges for exposed database ports, launching dictionary attacks and exploiting unpatched remote execution vectors.

Architecture Flow
flowchart LR
    A[Public Internet Scanner] -->|Port 5432 Probe| B[Linux VPS Host]
    B -->|Docker iptables PREROUTING| C[Docker Daemon Bridge]
    C -->|Bypasses UFW Completely| D[Postgres Container]
    style D fill:#ef4444,stroke:#991b1b,color:#ffffff
    style A fill:#f97316,stroke:#c2410c,color:#ffffff

In secrets, API keys, and rate limits, we emphasized that credential compromise rarely happens through sophisticated cryptographic breaks; it happens through exposed configuration mistakes. Exposing database and agent runtime ports to public interfaces is one of the most common ways self-hosted AI projects get compromised.

Why UFW does not protect Docker containers by default

To understand why your firewall failed to block traffic, you have to look at how Linux processes network packets.

UFW is an interface over iptables. When you configure UFW rules, it populates the INPUT chain in the filter table. When an incoming network packet arrives at the network card, it normally passes through the INPUT chain, where UFW drops unauthorized packets.

However, Docker manages its own network routing via network address translation (NAT). When Docker starts a container with published ports, it modifies the PREROUTING chain in the nat table and creates custom chains named DOCKER and DOCKER-USER.

Architecture Flow
flowchart TD
    Pack[Incoming Packet from Public IP] --> NatPre[nat PREROUTING Chain]
    NatPre --> RouteDecision{Is packet routed to Docker?}
    RouteDecision -->|Yes| FwdChain[FORWARD Chain: DOCKER Table]
    RouteDecision -->|No| InChain[INPUT Chain: UFW Rules]
    FwdChain --> Container[Container: Port Published to 0.0.0.0]
    InChain --> HostServices[Host Local Services: e.g. SSH]

When an external client attempts to connect to port 5432:

  1. The packet enters the Linux kernel and hits PREROUTING.
  2. Docker's NAT rule identifies that port 5432 should be routed to the internal container IP on the docker0 bridge.
  3. The kernel routes the packet through the FORWARD chain, completely bypassing the INPUT chain where UFW rules reside.
  4. The packet lands directly inside the container without UFW ever inspecting it.

Your firewall status shows ufw status: active, but Docker quietly opened a highway straight through your perimeter.

Building a zero-public-port mesh architecture

The safest production configuration for an independent builder or small engineering team is a Zero-Public-Port architecture.

In a zero-public-port model:

  1. Public Ingress (80/443 only): Only standard web traffic enters through the public internet via a hardened reverse proxy such as Nginx, Caddy, or Traefik.
  2. Internal Mesh Network: All administration interfaces, database connections, Redis caches, background workers, and agent execution daemons communicate over a private encrypted WireGuard mesh network powered by Tailscale.
  3. Loopback Isolation: Containers running on the same host that do not need to be accessible from remote machines bind strictly to 127.0.0.1 or internal Docker bridge networks.
Architecture Flow
flowchart LR
    subgraph Public Web Ingress
        PublicUser[Public Web Visitor] -->|HTTPS 443| RevProxy[Reverse Proxy: Nginx]
    end

    subgraph Linux VPS Private Perimeter
        RevProxy -->|Internal Bridge| WebApp[Next.js Web App]
        WebApp -->|Internal Network| Postgres[(Postgres DB)]
        WebApp -->|Internal Network| RedisCache[(Redis Cache)]
        Agent[Autonomous Agent Fleet] -->|Internal Network| Postgres
    end

    subgraph Private Mesh Access
        Operator[Operator Laptop or Phone] -->|Encrypted WireGuard Mesh| MeshInterface[Tailscale Private Mesh]
        MeshInterface -->|Private Node IP| DevTools[Admin Dashboard & Agent Logs]
        MeshInterface -->|Private Node IP| SSH[SSH Daemon: Port 22]
    end

By placing administrative access and database queries inside an encrypted mesh, you can shut down public SSH brute-force attempts and eliminate database port exposure entirely.

Hardening Docker compose and interface bindings

Fixing the Docker firewall leak does not require complex routing software. It requires understanding interface binding in your compose manifests.

When you specify ports: - "5432:5432", Docker defaults to binding on all host interfaces (0.0.0.0:5432:5432). To isolate a service to the local machine, prepend 127.0.0.1: to the host port:

yaml
services:  # Secured Database: Only accessible to local processes and Docker networks  database:    image: postgres:16-alpine    ports:      - "127.0.0.1:5432:5432"    environment:      POSTGRES_DB: app_production      POSTGRES_USER: app_admin      POSTGRES_PASSWORD_FILE: /run/secrets/db_password    secrets:      - db_password    networks:      - internal-backend  # Even Better: Omit host ports entirely if only other containers need access  redis:    image: redis:7-alpine    expose:      - "6379"    networks:      - internal-backendnetworks:  internal-backend:    driver: bridge    internal: truesecrets:  db_password:    file: ./secrets/db_password.txt

Notice the difference between ports and expose

  • ports: - "127.0.0.1:5432:5432" binds the port to the host loopback interface. A local script running on the VPS can access the database at localhost:5432, but no outside IP can connect.
  • expose: - "6379" publishes the port exclusively to containers on the same Docker network (internal-backend). The host machine does not even open a port on its network interfaces.

In deploying a first AI project, we noted that keeping internal service communication off host interfaces prevents accidental configuration drift when deploying multi-container stacks.

Enforcing Docker iptables protection in UFW

If you want UFW to strictly police forwarded traffic as well, you can configure Docker to respect UFW rules using the DOCKER-USER chain.

Edit /etc/ufw/after.rules on your Ubuntu host and add the following configuration block at the bottom of the file:

text
# Block external access to Docker container ports while allowing internal traffic*filter:DOCKER-USER - [0:0]-A DOCKER-USER -i tailscale0 -j ACCEPT-A DOCKER-USER -i lo -j ACCEPT-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT-A DOCKER-USER -j DROPCOMMIT

This rule ensures that any packet attempting to forward to a Docker container from an external public interface is dropped, while packets coming from the private mesh interface (tailscale0) or local loopback (lo) are accepted.

Reload UFW to apply the rules:

Terminalbash
sudo ufw reload

Accessing private agent dashboards and databases securely

When your databases and agent runtimes are locked down to 127.0.0.1 and your private mesh, how do you access them from your developer machine or phone?

You have two clean options that do not involve opening public ports.

Method 1: Connecting directly via private mesh IP

When Tailscale is installed on your VPS and your local machine, both devices share an encrypted point-to-point tunnel. Each machine receives a secure private mesh IP.

If you run an internal tool or dashboard on your VPS (such as a database management UI, an agent runtime agent monitoring console, or an Ollama local LLM endpoint), you can bind the container directly to your VPS private mesh IP:

yaml
services:  agent-console:    image: my-agent-dashboard:latest    ports:      # Replace with your VPS private mesh interface or loopback      - "127.0.0.1:8080:8080"

To view the dashboard, run tailscale serve on the VPS:

Terminalbash
tailscale serve --bg 8080

tailscale serve routes traffic from your private mesh network to the local port, giving you a secure, encrypted HTTPS endpoint accessible only to devices on your personal mesh. No public DNS, no firewall ports open to the world, and no certificates to manually renew.

Method 2: Secure SSH tunneling over the mesh

For direct database inspection using GUI tools like TablePlus, DBeaver, or pgAdmin, use an SSH tunnel over your private mesh:

Terminalbash
# Connect through your private mesh node without exposing port 5432 to the internetssh -L 5433:127.0.0.1:5432 operator@my-vps-node

In your desktop database client, configure the connection:

  • Host: 127.0.0.1
  • Port: 5433
  • User: app_admin

All traffic travels through the encrypted SSH tunnel directly to the VPS loopback interface. If an external attacker scans your public IP, port 5432 does not exist.

Production verification checklist

Before declaring your production VPS secure, run these verification commands on the host machine:

1. Verify open listening sockets on the host

Run ss to inspect all listening TCP sockets and verify that no database or cache ports are bound to 0.0.0.0:

Terminalbash
sudo ss -tulpn | grep -E '5432|6379|8080|3000'
  • Safe output: 127.0.0.1:5432 or empty (container uses internal network).
  • Dangerous output: 0.0.0.0:5432 or :::5432.

2. Verify UFW status and default policies

Confirm that your default incoming policy drops all traffic and only web traffic is permitted:

Terminalbash
sudo ufw status verbose

Verify that Default: deny (incoming), allow (outgoing), disabled (routed) or your custom DOCKER-USER chain is active.

3. Run an external port scan from an outside machine

From your local laptop (disconnected from your private mesh or testing via an external cellular hotspot), run nmap against your VPS public IP:

Terminalbash
nmap -Pn -p 22,80,443,3000,5432,6379,8080 YOUR_PUBLIC_VPS_IP

Only ports 80, 443, and your configured SSH port should show as open. Ports 5432, 6379, and 3000 must report filtered or closed.

4. Lock down SSH to the private mesh

Once you have verified that your private mesh connection is stable, you can prevent public SSH brute-force attempts entirely by restricting SSH to the mesh interface:

Terminalbash
# Allow SSH only through the private mesh interfacesudo ufw allow in on tailscale0 to any port 22 proto tcpsudo ufw delete allow 22/tcpsudo ufw reload

This single command eliminates 99% of automated SSH authentication log spam in /var/log/auth.log overnight.

For broader infrastructure resilience and disaster recovery strategies, reference our practical runbook on VPS infrastructure basics for vibe coders and openclaw agent runtime for beginners.

FAQ

Why does Docker bypass UFW firewall rules by default?

Docker configures network address translation (NAT) by writing rules directly into iptables before UFW rules execute. Packets destined for container ports are forwarded through the PREROUTING and FORWARD chains, bypassing the INPUT chain where UFW rules are evaluated.

How do I bind a Docker container port only to localhost?

In your docker-compose.yml file, prefix the host port with 127.0.0.1:. For example, change - "5432:5432" to - "127.0.0.1:5432:5432". This restricts the binding exclusively to the local loopback interface.

What is a zero-public-port architecture?

A zero-public-port architecture exposes only essential reverse proxy ingress ports (HTTP 80 and HTTPS 443) to the public internet. All databases, caches, administrative dashboards, and SSH access are moved onto an encrypted private mesh network like Tailscale.

How do I access internal admin tools on mobile without public ports?

Install the Tailscale client on your mobile device and log into the same private tailnet. Use tailscale serve on your VPS to expose internal web applications over private HTTPS, allowing you to access monitoring dashboards and logs securely from anywhere.

Share