Fix PHP PDO "SQLSTATE[HY000] [2002] Connection refused" Error

intermediate๐Ÿ˜ PHP2026-03-19| PHP 7.4โ€“8.x with pdo_mysql extension, MySQL 5.7/8.0 or MariaDB 10.x, Linux (Ubuntu 20.04/22.04, Debian, CentOS/RHEL), also reproducible in Docker/container environments

Error Message

Fatal error: Uncaught PDOException: SQLSTATE[HY000] [2002] Connection refused
#php#pdo#database#connection

The Error

Fatal error: Uncaught PDOException: SQLSTATE[HY000] [2002] Connection refused
Stack trace:
#0 /var/www/html/db.php(5): PDO->__construct('mysql:host=loca...', 'root', '***')
#1 {main}
  thrown in /var/www/html/db.php on line 5

Your app is returning 500s, logs are flooding, and requests are piling up. This error means PHP cannot open a TCP connection to MySQL at all โ€” it never even reaches authentication. Work through these steps in order.

Why This Happens

SQLSTATE[HY000] [2002] is a client-side network error: the MySQL client tried to connect and got refused. Five causes cover 95% of real-world cases:

  • MySQL/MariaDB service is stopped or crashed
  • Wrong host or port in the PDO DSN string
  • The localhost vs 127.0.0.1 Unix socket trap
  • MySQL bind-address blocking connections from your app's IP
  • Firewall rules dropping traffic on port 3306

Step 1: Is MySQL Actually Running?

Most common cause at 2 AM โ€” a service crashed and nobody noticed. Check it first.

sudo systemctl status mysql
# MariaDB
sudo systemctl status mariadb

If it shows failed or inactive, start it:

sudo systemctl start mysql
sudo systemctl status mysql

If it fails to start, read the logs before touching anything else:

sudo journalctl -u mysql -n 100 --no-pager
# or
sudo tail -100 /var/log/mysql/error.log

Three things kill MySQL at startup: disk full (df -h to check โ€” 100% disk is the #1 culprit), corrupted InnoDB files, or another process already sitting on port 3306 (sudo ss -tlnp | grep 3306). Fix the root cause, then move on.

Step 2: Verify the PDO DSN

A typo in the host, a wrong port, or an empty environment variable all produce [2002]. Dump your actual runtime values before assuming the DSN is correct:

<?php
// Print the real connection params your app is actually using
$host   = $_ENV['DB_HOST']   ?? getenv('DB_HOST')   ?: '(not set)';
$port   = $_ENV['DB_PORT']   ?? getenv('DB_PORT')   ?: '3306';
$dbname = $_ENV['DB_NAME']   ?? getenv('DB_NAME')   ?: '(not set)';
$user   = $_ENV['DB_USER']   ?? getenv('DB_USER')   ?: '(not set)';

echo "DSN will be: mysql:host={$host};port={$port};dbname={$dbname}\n";
echo "User: {$user}\n";

Three common mistakes: DB_HOST resolves to the wrong server (or not at all). Port is wrong โ€” MySQL defaults to 3306, but Docker setups often remap it to 3307 or 33060. Or the .env file was never loaded, so every variable comes back empty and the DSN ends up as mysql:host=;port=3306;dbname=.

Step 3: The localhost vs 127.0.0.1 Socket Trap

This one trips up PHP developers constantly. When you use host=localhost in a PDO DSN, the MySQL client library silently skips TCP and connects via a Unix socket file instead. If PHP and MySQL disagree on where that socket lives, you get [2002] even though MySQL is running perfectly.

// Uses Unix socket โ€” silently breaks if socket paths don't match
$dsn = 'mysql:host=localhost;dbname=myapp';

// Forces TCP โ€” works reliably in almost every setup
$dsn = 'mysql:host=127.0.0.1;dbname=myapp';

Check whether PHP and MySQL agree on the socket path:

# Where PHP's pdo_mysql looks for the socket
php -r "echo ini_get('pdo_mysql.default_socket') . PHP_EOL;"

# Where MySQL actually creates its socket
grep -E "^socket" /etc/mysql/mysql.conf.d/mysqld.cnf
# or
mysqladmin -u root -p variables 2>/dev/null | grep socket

Paths don't match? Switch to 127.0.0.1 in the DSN โ€” that's the fastest fix. Alternatively, set pdo_mysql.default_socket in php.ini to match MySQL's actual socket path, then reload PHP-FPM (sudo systemctl reload php8.2-fpm).

Step 4: Check MySQL bind-address

Got separate app and DB servers? Or a Docker setup where each container has its own network namespace? This is where bind-address bites you.

# Check what interface MySQL is listening on
grep bind-address /etc/mysql/mysql.conf.d/mysqld.cnf /etc/mysql/my.cnf 2>/dev/null

# Confirm with ss
sudo ss -tlnp | grep 3306

If bind-address = 127.0.0.1, MySQL rejects every non-local connection. Change it to allow remote access:

# /etc/mysql/mysql.conf.d/mysqld.cnf
[mysqld]
bind-address = 0.0.0.0
sudo systemctl restart mysql

Then give the MySQL user permission to connect from your app server's IP. Syntax differs by version:

-- MySQL 5.7 and below
GRANT ALL PRIVILEGES ON myapp.* TO 'myuser'@'APP_SERVER_IP' IDENTIFIED BY 'password';
FLUSH PRIVILEGES;

-- MySQL 8.0+: create user first, then grant
CREATE USER IF NOT EXISTS 'myuser'@'APP_SERVER_IP' IDENTIFIED BY 'password';
GRANT ALL PRIVILEGES ON myapp.* TO 'myuser'@'APP_SERVER_IP';
FLUSH PRIVILEGES;

Step 5: Check Firewall

# UFW
sudo ufw status verbose

# iptables
sudo iptables -L -n | grep 3306

# Test reachability from your app server (not the DB server)
nc -zv DB_SERVER_IP 3306
# or
telnet DB_SERVER_IP 3306

Connection refused or timing out? Add a firewall rule scoped to your app server's IP โ€” not open to the entire internet:

sudo ufw allow from APP_SERVER_IP to any port 3306
sudo ufw reload

Step 6: Docker / Container Environments

Running app and MySQL in separate containers? Both localhost and 127.0.0.1 resolve to the container itself โ€” not the MySQL container next to it. Use the service name defined in docker-compose.yml:

<?php
// 'mysql' is the service name in docker-compose.yml
$dsn = 'mysql:host=mysql;port=3306;dbname=myapp';
# Verify the containers can actually reach each other
docker exec app-container nc -zv mysql 3306

# Confirm the MySQL container is healthy
docker ps | grep mysql
docker logs mysql-container-name --tail 50

Verify the Fix

After any change, run this script directly on the server. Don't trust browser output โ€” it may be cached, queued, or hitting a different instance:

<?php
$dsn  = 'mysql:host=127.0.0.1;port=3306;dbname=myapp';
$user = 'myuser';
$pass = 'mypassword';

try {
    $pdo = new PDO($dsn, $user, $pass, [
        PDO::ATTR_ERRMODE  => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_TIMEOUT  => 5,
    ]);
    echo "Connected OK\n";
    echo "Query OK: " . $pdo->query('SELECT 1')->fetchColumn() . "\n";
} catch (PDOException $e) {
    echo "FAILED: " . $e->getMessage() . "\n";
}
php /tmp/test_db.php

You want: Connected OK followed by Query OK: 1. Still seeing [2002]? Read the error message carefully โ€” it usually includes the exact host and port PHP tried to reach. That's your clue to where the DSN is wrong.

Prevention Tips

  • Always set PDO::ATTR_TIMEOUT: Without it, a dead MySQL server stalls PHP workers indefinitely and takes down the whole app. Five seconds is a reasonable default โ€” fail fast, not frozen.
  • Use 127.0.0.1 instead of localhost in your DSN unless you have a specific reason to use Unix sockets.
  • Add a /healthz endpoint that runs SELECT 1 and returns HTTP 200 or 503 โ€” your load balancer can pull the instance from the pool before users see errors.
  • Enable MySQL auto-restart via systemd: add Restart=on-failure and RestartSec=5 to the MySQL service unit. Recovers from transient crashes without waking anyone up.
  • Monitor port 3306 with your uptime tool โ€” a 1-minute alert beats finding out from an angry user report.

Related Error Notes