Access a VPS Dashboard Without Opening It to the Internet

Published on

Keep Portainer, n8n and databases on 127.0.0.1 and reach them through an SSH tunnel, WireGuard or Tailscale instead of opening dashboard ports to everyone.

A VPS control panel with a disk usage bar and a bandwidth meter for incoming and outgoing traffic

You installed Portainer or n8n on your VPS and copied the guide's -p 9443:9443 or -p 5678:5678. Now the login page of a tool that holds your Docker socket, or the API keys of every workflow, answers on your public IP address. Portainer's own install page shows the result in docker ps: 0.0.0.0:9443->9443/tcp, [::]:9443->9443/tcp, open on every IPv4 and IPv6 address. You need that page from one computer a few times a week. Nobody else has to see it.

Quick answer: Publish each dashboard on 127.0.0.1 only, for example -p 127.0.0.1:9443:9443, because ports that Docker publishes skip UFW. Then open an SSH tunnel from your computer with ssh -N -L 9443:127.0.0.1:9443 user@your-server-ip and browse to https://localhost:9443. SSH already runs, so the server needs nothing new. When several people or devices need the dashboards every day, use a private network instead: WireGuard (one UDP port, keys you manage) or Tailscale (usually no open port, but you depend on its coordination service).

Step 1: Find What Listens on Every Address

Run these on the server. All server commands here work the same on Ubuntu 24.04 and Debian 12:

sudo ss -ltnp
sudo docker ps

ss lists listening (-l) TCP (-t) sockets with numeric ports (-n) and the process behind each (-p), per its man page. In the Local Address column, 0.0.0.0, [::] and * accept connections on every address; 127.0.0.1 and [::1] accept them only from the server itself. For containers, read the PORTS column of docker ps. A Space-Node VPS has both IPv4 and IPv6, so treat a [::] entry as just as public as 0.0.0.0.

Then sort the list. Web servers on 80 and 443 and game ports stay public. Admin panels, monitoring dashboards and databases can move off the public addresses.

Step 2: Bind Each Dashboard to 127.0.0.1

A UFW rule can't close a Docker port. Docker's docs say published container traffic "gets diverted before it goes through the ufw firewall settings", so ufw deny 9443 changes nothing; our Docker and UFW guide explains why. The fix is the address in the publish flag: with 127.0.0.1, "only the Docker host can access the published container port" (port publishing). The same page warns that in releases older than 28.0.0, hosts on the same network segment can still reach ports published to localhost, so update an older Docker first.

Portainer. To change its ports, remove the container and run it again, with the same stop and remove steps as Portainer's update guide. Its database lives in the portainer_data volume. Keep the image you installed: the command below uses portainer/portainer-ce:lts like the install guide, so swap in sts if docker ps lists that tag. Leave out -p 8000:8000: the install guide says that port is only needed for Edge agents.

sudo docker stop portainer
sudo docker rm portainer
sudo docker run -d -p 127.0.0.1:9443:9443 --name portainer --restart=always \
  -v /var/run/docker.sock:/var/run/docker.sock -v portainer_data:/data \
  portainer/portainer-ce:lts

n8n and other Compose apps. Change the mapping in the compose file, keeping the quotes that the Compose reference asks for, and run sudo docker compose up -d. It recreates changed containers "preserving mounted volumes" (compose up).

services:
  n8n:
    ports:
      - "127.0.0.1:5678:5678"

For n8n's owner account, logins and webhooks, follow our n8n security guide.

Databases. PostgreSQL's listen_addresses defaults to localhost, "which allows only local TCP/IP 'loopback' connections" (docs), and MariaDB says many of its packages bind to 127.0.0.1 (guide). If a tutorial changed that to *, 0.0.0.0 or your public IP, set it back and restart the database; PostgreSQL reads this setting only at start. A database container gets the same 127.0.0.1: prefix, or no published port at all when only other containers use it.

Step 3: Open the Dashboard Through an SSH Tunnel

On your own computer, open a terminal. On Windows 10 and 11 the same command runs in PowerShell; if ssh is missing, add OpenSSH through optional features (Microsoft).

ssh -N -L 9443:127.0.0.1:9443 user@203.0.113.10

-L listens on port 9443 of your computer and forwards each connection over the encrypted SSH session to 127.0.0.1:9443 as the server sees it. -N runs no remote command, which the ssh manual calls "useful for just forwarding ports". Keep the window open and browse to https://localhost:9443. Portainer secures 9443 with a self-signed certificate by default, so expect a certificate warning. Ctrl+C closes the tunnel.

Since versions 2.43 and 2.39.4, a new Portainer asks for a setup token, so nobody else can claim a fresh instance. Read the token from the log and create the admin user within 5 minutes (setup token FAQ):

sudo docker logs portainer 2>&1 | grep setup_token=

One SSH session can carry several tunnels. Use another local port when yours is taken, for example by a PostgreSQL on your laptop:

ssh -N -L 9443:127.0.0.1:9443 -L 5678:127.0.0.1:5678 -L 15432:127.0.0.1:5432 user@203.0.113.10

n8n is then at http://localhost:5678, and your database tool connects to 127.0.0.1 port 15432. These local ports sit on your computer's loopback address, so other devices on your network can't use them: GatewayPorts defaults to no (ssh_config). Don't add -g, which lets them in.

To skip the typing, add this to ~/.ssh/config on your computer and run ssh -N vps-admin:

Host vps-admin
    HostName 203.0.113.10
    User user
    LocalForward 9443 127.0.0.1:9443
    LocalForward 5678 127.0.0.1:5678
    ExitOnForwardFailure yes
    ServerAliveInterval 30

ExitOnForwardFailure yes makes ssh quit when a forward can't be set up, instead of staying connected without it. ServerAliveInterval 30 checks the connection every 30 seconds, and after three unanswered checks (the ServerAliveCountMax default) ssh disconnects, so a dead tunnel doesn't hang. The server side needs no change: AllowTcpForwarding defaults to yes (sshd_config).

Daily Use for a Team: WireGuard

A tunnel per session suits one person. When colleagues or your phone need the dashboards daily, a WireGuard link is easier. On a KVM VPS with full root access, such as Space-Node's, you install it like on any Linux server.

This setup gives your laptop the address 10.8.0.2, which reaches only the server's tunnel address, 10.8.0.1. The rest of your traffic keeps its normal route, so unlike a full VPN you don't set up IP forwarding or NAT for it; our WireGuard VPN guide covers the full version. On the server, install WireGuard and make a key pair as in the quick start:

sudo apt install wireguard
sudo sh -c 'umask 077; wg genkey | tee /etc/wireguard/server.key | wg pubkey > /etc/wireguard/server.pub'

Make a second key pair for your laptop, then create /etc/wireguard/wg0.conf on the server:

[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = <contents of /etc/wireguard/server.key>

[Peer]
PublicKey = <laptop public key>
AllowedIPs = 10.8.0.2/32

Set ListenPort yourself: wg(8) picks a random port when it's missing, and 51820 is the one its examples use. Open it (on Debian 12, install UFW first with sudo apt install ufw), then start the interface now and at every boot:

sudo ufw allow 51820/udp
sudo systemctl enable --now wg-quick@wg0

The laptop's configuration:

[Interface]
Address = 10.8.0.2/24
PrivateKey = <laptop private key>

[Peer]
PublicKey = <contents of /etc/wireguard/server.pub>
Endpoint = 203.0.113.10:51820
AllowedIPs = 10.8.0.1/32

Import it into the WireGuard app (Windows, macOS, Android and iOS), or on Linux save it as /etc/wireguard/wg0.conf and run sudo wg-quick up wg0 (wg-quick(8)). AllowedIPs = 10.8.0.1/32 is what keeps it an admin link: only traffic for the server's tunnel address goes in.

Now publish dashboards on the tunnel address instead of 127.0.0.1: -p 10.8.0.1:9443:9443, or "10.8.0.1:5678:5678" in Compose, and browse to https://10.8.0.1:9443. For a program outside Docker that listens on every address, a UFW rule does work, as long as UFW denies other incoming traffic: sudo ufw allow in on wg0 to any port 9090 proto tcp.

One catch after a reboot: Docker can only publish on 10.8.0.1 once wg0 exists. Docker's unit file and wg-quick@.service both start after network-online.target, and neither waits for the other. A container that loses that race stays down with bind: cannot assign requested address, as in this Docker forum report. Make Docker start after WireGuard:

sudo mkdir -p /etc/systemd/system/docker.service.d
printf '[Unit]\nAfter=wg-quick@wg0.service\nWants=wg-quick@wg0.service\n' | sudo tee /etc/systemd/system/docker.service.d/wireguard.conf
sudo systemctl daemon-reload

After= makes systemd finish starting WireGuard first, and Wants= starts it along with Docker (systemd.unit).

No Open Port at All: Tailscale

Tailscale builds the same kind of network on WireGuard, but its coordination service hands out keys and addresses, and you sign in with an account. Its firewall FAQ says that most of the time you don't need to open any firewall ports: it uses NAT traversal and falls back to its own relays. On the server:

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up

tailscale up prints a URL that adds the server to your tailnet (Linux install); install Tailscale on your own devices too. Then share the dashboards, which stay on 127.0.0.1:

sudo tailscale serve --bg 5678
sudo tailscale serve --bg --https=8443 https+insecure://localhost:9443

The first line proxies n8n at http://127.0.0.1:5678 to https://<machine>.<tailnet>.ts.net for devices in your tailnet; the second serves Portainer's self-signed HTTPS port on port 8443. With --bg the sharing survives reboots, and if your tailnet doesn't have HTTPS certificates on yet, the first run asks you to turn them on (Serve, CLI reference). Because Serve connects to 127.0.0.1, the reboot catch above doesn't apply. Don't use tailscale funnel here: Funnel shares a service with the whole internet.

Two settings to check. Without an acls section in your tailnet policy file, Tailscale applies its "default allow all policy" (access control), so every device you add can reach the server. And disable key expiry for the server in the admin console, or it has to sign in again after a set period.

Which One Fits

SSH tunnelWireGuardTailscale
New on the serverNothingA package, one UDP portA package, usually no open port
Who gets inAnyone with your SSH loginHolders of the keys you addDevices in your tailnet, per its policy
Dashboards listen on127.0.0.110.8.0.1127.0.0.1, shared with tailscale serve
FitsYou alone, now and thenA small team, daily useMany people and devices

Start with the SSH tunnel, which needs no new software. Move to WireGuard when a colleague or your phone needs access, or to Tailscale when you would rather not manage keys and accept a third-party coordination service. Whichever you pick, keep each dashboard's own login.

To confirm the old ports are closed, test from your computer with the tunnel and VPN off. nc with -z only checks for a listener, and -w 5 gives up after 5 seconds; if it reports success, the port is still public:

nc -zv -w 5 203.0.113.10 9443

If It Still Fails

bind [127.0.0.1]:9443: Address already in use, often followed by Could not request local forwarding. A program on your computer already uses that port. Pick another local port, such as -L 19443:127.0.0.1:9443, and open https://localhost:19443.

channel 3: open failed: connect failed: Connection refused (the number varies). The tunnel works, but nothing on the server listens at that address and port. Check sudo ss -ltnp: a dashboard you moved to 10.8.0.1 needs -L 9443:10.8.0.1:9443.

open failed: administratively prohibited: open failed. The server refuses forwarding. sudo sshd -T | grep -Ei 'allowtcpforwarding|disableforwarding|permitopen' prints the effective settings (sshd(8)); AllowTcpForwarding no, DisableForwarding yes or a PermitOpen list without your target causes it.

WireGuard is up, but the dashboard doesn't load. On the server, sudo wg show lists each peer's latest handshake. With no handshake, check UDP 51820 in UFW, the Endpoint address and that each side has the other's public key.

FAQ

Can't I just allow my own IP address in UFW? For programs outside Docker, yes: sudo ufw allow proto tcp from 198.51.100.7 to any port 9090, as long as your address doesn't change (ufw(8)). Docker's published ports ignore that rule, so containers still need the 127.0.0.1 binding or a DOCKER-USER rule from our Docker and UFW guide.

Is an SSH tunnel secure enough for database traffic? Each connection is forwarded "over the secure channel", encrypted like the rest of your SSH session, and only someone who can log in over SSH can open one. Keep the database password anyway.

How do I run the tunnel in the background? Use ssh -f -N -o ExitOnForwardFailure=yes -L 9443:127.0.0.1:9443 user@203.0.113.10. -f sends ssh to the background just before command execution, and ExitOnForwardFailure makes it exit instead of running without its forward.

Do I still need the dashboard's own login? Yes. The tunnel decides who can reach the login page; it doesn't replace the login. If the SSH key on your laptop ever leaks, the dashboard password is the next barrier.

Is Tailscale free? Its Personal plan is free for up to 6 users, but only for personal, non-commercial use; business tailnets and larger teams pay per user (pricing, checked 31 October 2026).

Sources

All opened on 31 October 2026: