Bind Services Private and Expose Only the Edge
Listen on loopback for app, database, and Redis. Bind the public IP only at nginx so scanners, scripts, and stray clients never reach the internals.
Netbay Engineering
Netbay Engineering
On this page
The public surface of a VPS should be a short list: 22 for SSH from your admin network, 80 and 443 for the reverse proxy, and nothing else. Everything else — the app, Postgres, Redis, a metrics exporter, a debug port you opened last Tuesday — binds 127.0.0.1 or a Unix socket. Binding *:3000 because it was the framework default is how a staging dashboard ends up on Shodan. This is not a cluster trick and it is not a product for forwarding outbound client traffic. It is the listen address of each daemon on one Linux box.
Inventory what is actually listening
Before you change config, look. ss shows the truth; firewall rules do not. A process bound to 0.0.0.0 is reachable on the public IP the moment the datacenter route exists, even if you intended ufw to save you.
# what the internet can see vs what is loopback-only
ss -ltnp
ss -ltnp | awk '$4 ~ /127.0.0.1|::1/ { print "local " $4, $6 }'
ss -ltnp | awk '$4 ~ /0.0.0.0|:::/ { print "public " $4, $6 }'
# expected on a tight box:
# public 0.0.0.0:22 sshd
# public 0.0.0.0:80 nginx
# public 0.0.0.0:443 nginx
# local 127.0.0.1:3000
# local 127.0.0.1:5432
# local 127.0.0.1:6379If Postgres appears on 0.0.0.0:5432, fix Postgres, do not "allow it in ufw later." Firewall policy is the second fence. The first fence is the listen address. A packet that never has a socket never becomes an incident. L3/L4 DDoS filtering in front of the VPS drops volumetric noise; it does not close an open Redis.
Pin every daemon to loopback
Framework defaults are public-friendly. Override them in the first hour. Node and Python take a bind host. Postgres takes listen_addresses. Redis takes bind. Nginx is the exception that is allowed to be public, and even then it should proxy_pass only to loopback.
# postgres: /etc/postgresql/16/main/postgresql.conf
listen_addresses = '127.0.0.1'
port = 5432
# redis: /etc/redis/redis.conf
bind 127.0.0.1
protected-mode yes
# app env
BIND=127.0.0.1
PORT=3000Restart each unit and re-run ss. If you need a local GUI or a migration laptop, use SSH local forwarding: ssh -L 5432:127.0.0.1:5432 user@vps. That keeps Postgres off the public IP without installing a second access layer. Do not open 5432 in the firewall "just for today." Today becomes a year.
Firewall as the second fence, not the first
ufw or nftables should drop everything inbound except 22, 80, and 443. That policy is worthless if Redis is bound to 0.0.0.0 and you later allow a port range. Keep the policy boring and keep the listen addresses stricter than the policy.
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw --force enable
ufw status verboseSSH itself should not stay password-open to the world. Keys only, and if you can, a second allow-list of admin IPs. The VPS still has one public address in Lucknow DC01; shrinking who may knock on port 22 is the same idea as shrinking who may knock on port 3000. Metrics exporters belong on loopback and are scraped by a local process, or they are reached through an SSH tunnel. Publishing :9100 to the internet is a free map of your kernel.
When a new service appears, the review is one line: what does ss say? If the answer includes 0.0.0.0 and the process is not nginx or sshd, the service is not done. Bind it private, expose only the edge, and keep the rest of the stack off the routing table.
Unix sockets where a TCP port is not required
Postgres and Redis can go one step further than 127.0.0.1. A Unix socket has no TCP port to mis-bind later, and it is slightly faster on the same host. Postgres already creates one under /var/run/postgresql. Point DATABASE_URL at the socket directory instead of a host and port, and there is no 5432 to forget about in ss. Redis can do the same with unixsocket in redis.conf and a file mode of 770 owned by redis:app. Use TCP loopback when a tool cannot speak Unix sockets; use a socket when it can. Either way, the packet never meets the public IP.
IPv6 is the bind people skip. A daemon that listens on * for IPv4 and also on :: for IPv6 is public on both. Check ss -ltnp for :::5432 as carefully as for 0.0.0.0:5432. Set listen_addresses to 127.0.0.1 only, not localhost, if localhost resolves to ::1 and your app is not connecting over IPv6. Dual-stack loopback is fine; dual-stack public Redis is not. Re-run the inventory after every package update, because installers love to reset bind addresses to the vendor default.
A fresh Ubuntu 24.04 VPS on Netbay starts with a public IP in Lucknow; lock the listen table before you ship traffic — netbayhosts.in.
Keep reading
Follow along on a real VPS
Deploy Linux in under 60 seconds
These guides are written against Ubuntu, Debian, and RHEL-family images — the same ones on NetBay.
Deploy an instance