Cloud Explainers
Home › Networking › Networking for Beginners, episode 8

Never Expose a Database to the Internet

Networking8 min videoPublished Oct 30, 2026

Why you should never expose a database to the internet, for complete beginners, on three real Linux machines in our lab: Ana's shop server, her laptop, and a stranger's computer standing in for anyone on the internet. Ana turns on ufw (deny incoming) and starts PostgreSQL in Docker the way many tutorials do (-p 5432:5432, password postgres). The stranger's nmap scan finds port 5432 open, his fourth password guess logs in, and he reads 1,200 customers and runs a program inside the database's container. We see why the firewall didn't help (Docker's own rules redirect the packet before ufw sees it), then fix it: publish the port on 127.0.0.1 only, change the password, and reach the database through an SSH tunnel. Last, the same rule for bigger shops and for AWS RDS.

Never Expose a Database to the Internet: the full diagram
The whole picture, from the video's recap.

An open door

Ana's shop database answered a stranger on the internet, and four password guesses later, he was reading her customer list. She even had a firewall turned on. Today we replay it on real machines, see why the firewall didn't help, and fix it the right way.

Three small Linux machines in our lab play the parts: Ana's shop server, her laptop, and a stranger's computer, standing in for anyone on the internet. They share a private lab network, so nothing outside it is touched. In episode seven, a scan found Ana's database open. Today we see what that costs.

What exposed means

First, what exposed means. A database is a program that waits for connections on a numbered door, called a port. PostgreSQL uses port 5432. Ana's website connects to that port to read products and orders. Exposed means anyone on the internet can reach that port too, not just her website.

What exposed means: diagram from the video

Here's how Ana set up her server. She turned on ufw, Ubuntu's simple firewall: deny everything coming in, allow only SSH. Then she started PostgreSQL with Docker, a tool that runs programs in sealed boxes called containers, the way many tutorials show it. The password is postgres, and -p 5432:5432 publishes the port: port 5432 on the server leads to port 5432 in the container. With no address in front, Docker uses every address: 0.0.0.0.

Ana's shop server
sudo ufw status verbose[sudo] password for ana:
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp (OpenSSH)           ALLOW IN    Anywhere
22/tcp (OpenSSH (v6))      ALLOW IN    Anywhere (v6)
Ana's shop server
docker run -d --name shop-db -e POSTGRES_PASSWORD=postgres -p 5432:5432 -v shop-data:/var/lib/postgresql postgres:18a06b1c9bdfa3bffd7cd940e21b78e0df5bb8da2fcf7be1a4ed5878e2a9a0caba
Ana's shop server
docker ps --format 'table {{.Names}}\t{{.Ports}}'NAMES     PORTS
shop-db   0.0.0.0:5432->5432/tcp, [::]:5432->5432/tcp

0.0.0.0 means every network address this server has, including its public one, and the [::] next to it is the same for the newer IPv6 addresses. So the door is open to the whole internet. And the firewall says deny incoming. Which one wins?

A stranger gets in

Out on the internet, automated bots scan every address, all day. A tool like masscan can sweep all of IPv4 in under five minutes, so a new server gets found fast.

A stranger gets in: diagram from the video

Our stranger runs nmap against Ana's server, checking SSH and four common database ports. MySQL, Redis and MongoDB are filtered: the firewall drops them. But PostgreSQL is open.

A stranger's computer
nmap -Pn -p 22,3306,5432,6379,27017 shop | grep -E 'report|/tcp'Nmap scan report for shop (10.236.135.4)
22/tcp    open     ssh
3306/tcp  filtered mysql
5432/tcp  open     postgresql
6379/tcp  filtered redis
27017/tcp filtered mongod

Open means it answers, so the next step is guessing. The stranger's list has four common passwords for postgres, the database's built-in admin user, and a small script of ours tries each one, the way bots do. Three are wrong. The fourth, postgres, the password from the tutorial, logs in.

A stranger's computer
cat common-db-logins.txtpostgres 123456
postgres admin
postgres password
postgres postgres
A stranger's computer
guess shop common-db-logins.txtpostgres  123456    wrong password
postgres  admin     wrong password
postgres  password  wrong password
postgres  postgres  LOGGED IN

He's in, as the superuser: the account that may do anything. He lists the databases, and one is called shop. It holds 1,200 customers, with names and email addresses. Ours are made up; on a real shop, this is a data breach. And then he asks PostgreSQL to run a program, the id command, and it does: inside the database's container, on Ana's server.

A stranger's computer
export PGHOST=shop PGUSER=postgres PGPASSWORD=postgres
A stranger's computer
psql -Atc 'select datname from pg_database'postgres
shop
template1
template0
A stranger's computer
psql -d shop -c 'select count(*) from customers' count
-------
  1200
(1 row)
A stranger's computer
psql -d shop -c 'select name, email from customers limit 3'    name    |          email
------------+-------------------------
 Leo Silva  | leo.silva1@example.com
 Iris Novak | iris.novak2@example.com
 Omar Reyes | omar.reyes3@example.com
(3 rows)
A stranger's computer
psql -Atc "create temp table o (t text); copy o from program 'id'; select * from o"CREATE TABLE
COPY 1
uid=999(postgres) gid=999(postgres) groups=999(postgres),101(ssl-cert)

That's the worst part. PostgreSQL has a built-in command, COPY FROM PROGRAM, that lets a superuser run programs on the machine the database runs on: here its container, and on a normal install, the server itself. In 2024, security researchers found malware doing exactly this. Its name is PG_MEM. It guessed weak passwords on exposed PostgreSQL servers, then used this feature to install a cryptocurrency miner.

A stranger gets in: diagram from the video

On Ana's server, the database's log shows 3 failed passwords. The login that worked left no line at all, because PostgreSQL doesn't log connections unless you ask it to. Ana would never know.

Ana's shop server
docker logs shop-db 2>&1 | grep -o 'FATAL.*'FATAL:  password authentication failed for user "postgres"
FATAL:  password authentication failed for user "postgres"
FATAL:  password authentication failed for user "postgres"

Why the firewall missed it

So why didn't the firewall stop him? ufw guards traffic that arrives for the server itself. But Docker writes its own rules, earlier on the packet's path. A packet for port 5432 is redirected to the container before ufw's rules for incoming traffic ever see it, and Docker's next rule lets it through. Docker's own documentation warns about this.

Why the firewall missed it: diagram from the video

Here is that rule on Ana's server, listed with iptables, the Linux tool that shows the firewall rules both ufw and Docker write. Any packet for port 5432 goes to the container's own address. It's not a ufw rule, so ufw status never showed it, and her firewall looked safe.

Ana's shop server
sudo iptables -t nat -S DOCKER | grep 5432-A DOCKER ! -i docker0 -p tcp -m tcp --dport 5432 -j DNAT --to-destination 172.17.0.2:5432

Listen on this machine only

The fix is to choose where the database listens. Her website runs on the same server, so the database only has to answer that one machine. The address for that is 127.0.0.1, called localhost: it never leaves the server.

Listen on this machine only: diagram from the video

Ana removes the container. The data stays in its volume, a folder Docker keeps. She starts it again with one change: 127.0.0.1 before the port. Now Docker lists localhost only. And since postgres was guessed, she sets a long random password, typed twice and never shown.

Ana's shop server
docker rm -f shop-dbshop-db
Ana's shop server
docker run -d --name shop-db -e POSTGRES_PASSWORD=postgres -p 127.0.0.1:5432:5432 -v shop-data:/var/lib/postgresql postgres:18bd26ae854113cad275ce432d61329c4c0b55106f40952519b893bb9a465346eb
Ana's shop server
docker ps --format 'table {{.Names}}\t{{.Ports}}'NAMES     PORTS
shop-db   127.0.0.1:5432->5432/tcp
Ana's shop server
docker exec -it shop-db psql -U postgres -c '\password postgres'Enter new password for user "postgres":
Enter it again:

The stranger tries again. Port 5432 is filtered now, like the other database ports: nothing answers. Every guess times out. There's nothing left to guess against.

A stranger's computer
nmap -Pn -p 22,3306,5432,6379,27017 shop | grep -E 'report|/tcp'Nmap scan report for shop (10.236.135.4)
22/tcp    open     ssh
3306/tcp  filtered mysql
5432/tcp  filtered postgresql
6379/tcp  filtered redis
27017/tcp filtered mongod
A stranger's computer
guess shop common-db-logins.txtpostgres  123456    no answer (timed out)
postgres  admin     no answer (timed out)
postgres  password  no answer (timed out)
postgres  postgres  no answer (timed out)

On the map, the stranger's packets now stop at the server's edge, while the website still talks to the database inside the same machine.

Here's the check on the server: connecting to localhost, with the new password, the database answers. All 1,200 customers are still there.

Ana's shop server
psql -h 127.0.0.1 -U postgres -d shop -c 'select count(*) from customers'Password for user postgres:
 count
-------
  1200
(1 row)

Ana's own way in

But what about Ana herself? Sometimes she wants the database from her laptop. Opening the port for her would open it for everyone. Instead, she uses an SSH tunnel: her laptop logs in over SSH with her key, and SSH carries the database connection inside, to localhost on the server.

Ana's own way in: diagram from the video

One command opens the tunnel in the background: port 5432 on her laptop now leads to port 5432 on the shop server. Then she connects to localhost on her own laptop, types the new password, and sees all 1,200 customers.

Ana's laptop
ssh -f -N -L 5432:localhost:5432 shop
Ana's laptop
psql -h localhost -U postgres -d shop -c 'select count(*) from customers'Password for user postgres:
 count
-------
  1200
(1 row)

Bigger shops and the cloud

When a shop grows, the database gets its own server, and the rule stays the same. Put it on a private network, with no public address at all. Only the web servers can reach it. Admins tunnel in with SSH, through a server they're allowed to log in to, the way Ana's tunnel did.

Bigger shops and the cloud: diagram from the video

In the cloud it's the same idea, with two settings to check. On AWS, RDS, Amazon's managed database service, has an option called Publicly accessible: leave it at No. And its security group, the cloud's firewall, should allow port 5432 only from the web servers' security group, never from 0.0.0.0/0, which means anywhere.

Bigger shops and the cloud: diagram from the video

So, the checklist. Scan your own server from outside, so you see what a stranger sees. Publish database ports to localhost, or keep them on a private network. Don't count on ufw to block Docker's ports. Use a long random password anyway. And reach the database yourself through SSH.

Bigger shops and the cloud: diagram from the video

Recap

Back to Ana's shop. One tutorial flag published her database to the internet, and Docker's rules went around her firewall. A stranger guessed the password on his fourth try, read her customers, and ran a program on her server. Listening on localhost closed the door, and an SSH tunnel kept her own way in.

A question for the comments: have you ever found a port open that you were sure your firewall was blocking? What was it?

Recap: diagram from the video

Every command and log line in this video came from a real run in our lab. This was episode eight of Networking for Beginners.

Sources

Demos: real output from three Ubuntu containers on a private network on our own machine. The scan and password guessing ran only against our own lab server; the customers are made up and the passwords are throwaway lab values. Watch the full video on YouTube.

More videos

Cybersecurity10 min

SSH Keys vs Passwords: Which Is Safer for Security?

Linux10 min

The 5 Levels of Linux Boot: From Power On to Login

Networking12 min

The 5 Levels of Network Firewalls

Cybersecurity10 min

Password Security: Why Hackers Crack Weak Passwords

Linux9 min

Why Your Linux Server Runs Out of Memory

Networking10 min

HTTP vs HTTPS: What Actually Changes on Your Network

Linux12 min

Every Linux File Permission You Need to Know

Linux10 min

Linux Processes vs Threads: When Each One Wins

Networking11 min

The OSI Model Will Finally Make Sense After This

Git11 min

The 7 Levels of Git

Networking10 min

Every Network Troubleshooting Command You Need

Cybersecurity10 min

Hashing vs Encryption in Cybersecurity: When Each One Wins

Networking11 min

Why the Internet Ran Out of IP Addresses

Linux11 min

Linux Package Managers: apt vs Snap vs Flatpak

Python9 min

Why Python Is Slow (and When It Doesn't Matter)

Networking11 min

TCP vs UDP: When Each One Wins

Linux8 min

Stop Running Linux as Root (Do This Instead)

Linux11 min

Linux Users and Groups Will Finally Make Sense

Cybersecurity9 min

Why Phishing Beats Good Security (and How to Spot It)

Docker12 min

The 7 Levels of Docker

AI agents & MCP12 min

AI Agents Will Finally Make Sense After This

Git8 min

Stop Using git push --force (Use This Instead)

AI agent skills10 min

Agent Skills vs MCP: When Each One Wins

Networking12 min

Networking Will Finally Make Sense After This

Linux12 min

Every Linux Command You Actually Need

Cybersecurity12 min

The 7 Levels of Cybersecurity

System design12 min

Every System Design Concept You Need, in One Diagram

PostgreSQL10 min

Why PostgreSQL Ignores Your Index