How to Expose MySQL Safely in Docker Compose
Almost every Docker Compose tutorial, including the files this site generates, includes a line like this on the database service:
services:
mysql:
image: mysql:8
ports:
- "3306:3306"It's what lets you point TablePlus, DBeaver, or a local script at 127.0.0.1:3306. It's also more exposure than most people realize, and the line that turns a convenient local default into a real problem when copy-pasted somewhere it shouldn't be.
What "3306:3306" actually does
The short form "3306:3306" is shorthand for "0.0.0.0:3306:3306" — it binds the port on every network interface on the host, not just the loopback interface. On a laptop at home behind a router, that mostly just means "anyone else on your Wi-Fi could reach it," typically low risk. On a cloud VM with a public IP, it means the database is reachable from the entire internet the moment the container starts — no firewall rule required to get in, because Docker's own iptables rules for published ports take precedence over a lot of default firewall setups (a well-known surprise: ufw rules alone often don't block Docker-published ports).
The actual risk scenario
Nobody deliberately deploys a database to the open internet with the default app / secret credentials from a generated .env.example. It happens by omission: a compose file written for local development gets reused as-is for a staging server, because it already works and nobody revisits the ports: section. Automated scanners find newly-opened database ports within hours.
Three fixes, in order of how often you need them
1. Bind to localhost only
services:
mysql:
image: mysql:8
ports:
- "127.0.0.1:3306:3306"This is almost always the right default. Your host tools can still connect via 127.0.0.1:3306, but nothing outside the machine can reach it — remote access now requires an SSH tunnel, which is exactly the extra step you want for a database.
2. Don't publish the port at all if only containers need it
If your PHP application is the only thing that talks to MySQL, it doesn't need the ports: line at all — containers on the same Compose network already reach each other directly via service name and internal port (mysql:3306), with zero host exposure. Only add ports: when you specifically need a tool running on your host machine to connect in.
3. Change the default credentials before anything but localhost sees the container
The generated MYSQL_PASSWORD=secret is meant to be changed the moment this stack runs anywhere besides your own machine. Treat "still using the generated default" the same as "no password."
A firewall is a second layer, not a substitute
Even with 127.0.0.1 binding, a compromised process on the same host could still reach the database — host-level firewalling and least-privilege database users remain worth having. The ports: binding is the first, cheapest layer, not the only one.
Generate a ready-to-run Docker Compose setup for your stack — compose.yml, nginx config, .env, and a README, in seconds.
Open the generator →