A database on a server is of no use if your application, your laptop, or a colleague cannot reach it. But MySQL exposed to the internet is attacked constantly. This lesson shows how to allow the connections you need and nothing more.
Layers of control
A remote connection must pass four checks, in this order:
- Network: can the packet reach the server? (firewall, security group, routing)
bind_address: does MySQL listen on that network interface?- Account host: does an account exist for that user and that client host?
- Privileges: what may that account do?
Failing at any of them gives a different error, which helps with diagnosis (below).
1. bind_address: where MySQL listens
SELECT @@bind_address AS listens_on, @@port AS port, @@skip_networking AS tcp_disabled, @@skip_name_resolve AS no_dns;
+------------+------+--------------+--------+
| listens_on | port | tcp_disabled | no_dns |
+------------+------+--------------+--------+
| 127.0.0.1 | 3384 | 0 | 0 |
+------------+------+--------------+--------+
1 row in set
bind_address | Meaning |
|---|---|
127.0.0.1 | Only local TCP connections (safest). Remote clients cannot connect |
* (the default of many installs) | All IPv4 and IPv6 interfaces |
10.0.0.5 | Only that interface (a private network address) |
0.0.0.0 | All IPv4 interfaces |
For a database that only a web server on the same machine uses, keep 127.0.0.1 and no remote access at all. For a separate database server, bind it to the private network interface, never to a public address.
2. The firewall
Port 3306 (MySQL) should be reachable only from the machines that need it:
# Linux (ufw): allow the web server only
ufw allow from 10.0.0.12 to any port 3306 proto tcp
ufw deny 3306
On cloud providers, use security groups / firewall rules the same way: allow the application subnet, deny the rest. Never allow 0.0.0.0/0 (the whole internet) to port 3306.
3 and 4. Accounts for the right hosts
CREATE USER 'shop_app'@'10.0.0.12' IDENTIFIED BY 'Ap9!pass#Long#Random#1';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'shop_app'@'10.0.0.12';
SELECT user, host FROM mysql.user WHERE user = 'shop_app';
+----------+-----------+
| user | host |
+----------+-----------+
| shop_app | 10.0.0.12 |
+----------+-----------+
1 row in set
The account is valid only from the web server’s address. A connection from any other host is refused, even with the right password (Creating Users).
Connecting from your own computer: the SSH tunnel
For administration and development, you rarely need to open MySQL to your own IP. Use an SSH tunnel: the database port stays closed to the world, and your tool talks through an encrypted SSH connection:
# local port 3307 -> (through SSH to the server) -> MySQL on 127.0.0.1:3306 there
ssh -N -L 3307:127.0.0.1:3306 deploy@db.example.com
Then connect your client to 127.0.0.1:3307:
mysql -h 127.0.0.1 -P 3307 -u shop_report -p
MySQL sees a local connection from the server itself ('shop_report'@'localhost'). MySQL Workbench, DBeaver and others have an “SSH tunnel” option built in. Only people with SSH access to the server can reach the database, and everything is encrypted.
Other safe options
| Option | Good for |
|---|---|
| Private network / VPC with security groups | Application servers and database servers in the same cloud |
| VPN (WireGuard, OpenVPN, a cloud VPN) | Teams that need regular access to private networks |
| A bastion host | Controlled, logged entry to the private network |
| Managed database proxies and IAM-based auth (cloud) | Short-lived credentials instead of long-term passwords |
| TLS with client certificates | Strong identity for service-to-service connections (Encrypted Connections) |
Shared hosting
Many shared hosts block remote MySQL on purpose. They offer “Remote MySQL” in the control panel, where you add the IP address that may connect. Add only the addresses you really need (not %). Otherwise use their web phpMyAdmin or an SSH tunnel if SSH is available.
Diagnose connection errors
| Error | Layer | Meaning and fix |
|---|---|---|
ERROR 2003 (HY000): Can't connect to MySQL server on 'host' (110) or (111) | Network / bind | Timeout (110): a firewall drops the packets. Refused (111): nothing listens there (server down, wrong port, or bind_address is 127.0.0.1) |
ERROR 1130 (HY000): Host '10.0.0.7' is not allowed to connect to this MySQL server | Account host | No account matches the client’s host. Create the account for that host, or use the right one |
ERROR 1045 (28000): Access denied for user 'x'@'10.0.0.7' (using password: YES) | Account / password | Wrong password, or a different account matched first (check the host shown in the message) |
ERROR 2013 (HY000): Lost connection to MySQL server at 'reading initial communication packet' | Network | Often a firewall or a TCP wrapper in the middle, or skip_name_resolve and DNS problems |
ERROR 3159 (HY000): Connections using insecure transport are prohibited while --require_secure_transport=ON | TLS | The server requires TLS, the client connected without it |
The host in the “Access denied” message is the one MySQL saw, which tells you which account it tried to match. If you connected through a NAT, a proxy or a tunnel, MySQL sees that address, not your laptop’s.
Test from the client side
# is the port reachable?
nc -vz db.example.com 3306 # Linux, macOS
Test-NetConnection db.example.com -Port 3306 # Windows PowerShell
and from MySQL itself, after connecting:
SELECT CURRENT_USER() AS matched_account, USER() AS connected_as, @@hostname AS server_host;
+----------------------+------------------------+-------------+
| matched_account | connected_as | server_host |
+----------------------+------------------------+-------------+
| shop_app@10.0.0.% | shop_app@10.0.0.12 | db1 |
+----------------------+------------------------+-------------+
(An example of what you would see.) CURRENT_USER() is the account whose privileges apply. USER() is how the client identified itself and from where.
skip_name_resolve
By default, MySQL tries to resolve client IP addresses to host names (a reverse DNS lookup). That is slow, and breaks when DNS has problems. With skip_name_resolve = ON, MySQL uses IP addresses only, so every account host must be an IP or a pattern, not a name. It is the usual production setting.
What not to do
bind_address = 0.0.0.0andGRANT ... TO 'root'@'%': the classic combination that gets servers ransomed.- Opening 3306 to the internet “just for a minute”.
- Using the same account and password for the website and for remote administration.
- Sending passwords over unencrypted connections across networks.
Within an hour of a new server’s port 3306 being exposed, bots start trying the common passwords for root, admin and mysql.