The Ultimate 'Connection Refused' Guide: A PHP PDO & Docker Debugging Saga of a Forgotten Port

Published: 2025-12-03
Author: DP
Views: 8
Category: PHP
Content
## The Scene: An "Impossible" Error Imagine this scenario: you're developing a project where a PHP application runs in a Docker container on your local Mac, while the MySQL database resides in another Docker container on a NAS within the same local network. Using MySQL Workbench, you can connect perfectly to the database via the NAS's LAN IP `192.168.1.2` and a custom port `33221`. However, when you use the exact same parameters (IP, port, username, password) in your PHP code to connect via PDO, you're greeted with a cold, hard error: ``` SQLSTATE[HY000] [2002] Connection refused ``` The network is up, the credentials are correct, and the GUI tool works. So, what on earth is wrong? This is the mystery we're unraveling today. This case comes from a real technical support session at `wiki.lib00.com`, and it's a stark reminder that the most complex bugs often stem from the simplest oversights. --- ## Phase 1: The Standard Checklist (Eliminating Red Herrings) When faced with a "Connection refused" error, our first instinct is to check the usual suspects: networking and permissions. This is a standard and necessary troubleshooting process. 1. **MySQL User Permissions**: The most common culprit is MySQL's user grant configuration. MySQL users are defined as `'username'@'hostname'`. The first step was to confirm that a rule existed allowing connections from the host of the PHP container. We executed a query via MySQL Workbench: ```sql SELECT user, host FROM mysql.user WHERE user = 'lee.dev'; ``` The result was `lee.dev | %`. The `%` wildcard means connections are allowed from **any host**. With that, we ruled out permissions. 2. **Docker Network Isolation**: Since PHP is in a Docker container, its network environment differs from the host machine (the Mac). We initially suspected that the MySQL server was seeing the request come from Docker's internal IP (e.g., `172.18.0.5`) instead of the Mac's LAN IP. However, since the connection target is a LAN IP (`192.168.1.2`), Docker NATs the traffic, so the source IP seen by the NAS is indeed the Mac's LAN IP. 3. **Firewalls and `bind-address`**: Because Workbench could connect, it meant the NAS firewall was allowing traffic from the Mac's IP to port `33221`. Similarly, the `bind-address` in MySQL's configuration file (my.cnf) had to be correct (e.g., `0.0.0.0`), or it wouldn't be listening for any external connections at all. With all the usual suspects cleared, the mystery deepened. --- ## Phase 2: Going Inside the Container (Finding the Truth) When external observation fails, we must go to the source of the problem—inside the PHP container—and see the network from its perspective. 1. **Get a shell inside the container**: ```bash docker exec -it <php_container_id> /bin/sh ``` 2. **Perform network connectivity tests**: We used the powerful `curl` utility to test the TCP connection. `telnet` or `nc` are also great choices, but `curl` is more commonly available. ```bash # Test to the NAS's port 80 to confirm basic network reachability root@...# curl 192.168.1.2 # Successfully returned HTML, proving the container-to-NAS network path is clear # The critical test: access the MySQL port directly root@...# curl 192.168.1.2:33221 curl: (1) Received HTTP/0.9 when not allowed ``` The `curl` error `Received HTTP/0.9 when not allowed` was **decisively good news**! Although `curl` can't parse MySQL's proprietary protocol, this error confirms that: * The TCP three-way handshake was **successful**. * A connection was **established** between the PHP container and the MySQL service on the NAS at port `33221`. * The MySQL service **responded** to the connection request. At this point, we knew with 100% certainty that **the network, firewalls, port mapping, user permissions, and server configuration were all correct!** The problem was now isolated to the final link in the chain: the PHP PDO client itself. --- ## Phase 3: Code Isolation (The Minimal Test) To eliminate interference from the project's complex configuration or framework, we created a minimal, standalone PHP script named `db_test.php`, authored by `DP@lib00`, solely for testing the database connection. ```php <?php ini_set('display_errors', 1); error_reporting(E_ALL); $config = [ 'host' => '192.168.1.2', 'database' => 'lm801_12', 'username' => 'lee.dev', 'password' => 'lee.dev.00', 'charset' => 'utf8mb4', 'port' => 33221, // Port is explicitly included ]; $dsn = "mysql:host={$config['host']};port={$config['port']};dbname={$config['database']};charset={$config['charset']}"; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION ]; echo "Attempting to connect... DSN: " . $dsn . " "; try { $pdo = new PDO($dsn, $config['username'], $config['password'], $options); echo "✅ Connection successful! MySQL Server version: " . $pdo->getAttribute(PDO::ATTR_SERVER_VERSION) . " "; } catch (PDOException $e) { echo "❌ Connection failed! Error: " . $e->getMessage() . " "; } ``` Executing this script from within the container yielded: ```bash root@...# php scripts/db_test.php Attempting to connect... DSN: mysql:host=192.168.1.2;port=33221;dbname=lm801_12;charset=utf8mb4 ✅ Connection successful! MySQL Server version: 5.7.40 ``` **The connection was successful!** --- ## The Final Culprit: A Forgotten Parameter If the minimal script worked while the project code failed, the answer was clear: the bug was in the project's own database connection logic. Let's examine the project's code: ```php // file: config/database.lib00.php return [ 'host' => '192.168.1.2', 'database' => 'lm801_12', 'username' => 'lee.dev', // ... 'port' => 33221, // The port is in the config file ]; // file: Database.php (The project's connection logic) private function __construct() { $config = require_once __DIR__ . '/../config/database.lib00.php'; $this->host = $config['host']; $this->database = $config['database']; $this->username = $config['username']; $this->password = $config['password']; $this->charset = $config['charset']; // The fatal flaw: the code never reads or uses $config['port'] try { // The DSN string is missing the port! $dsn = "mysql:host={$this->host};dbname={$this->database};charset={$this->charset}"; $this->connection = new PDO($dsn, $this->username, $this->password, ...); } catch (PDOException $e) { // ... } } ``` **The Root Cause**: The project code **omitted the `port` parameter** when constructing the DSN (Data Source Name) string. When the port is not specified in the DSN, PDO defaults to MySQL's standard port, `3306`. Consequently, the application was constantly trying to connect to `192.168.1.2:3306`, a port where no service was listening. The operating system kernel naturally and immediately responded with `Connection refused`. Interestingly, the problematic code was originally generated by an AI (Claude Sonnet), which serves as a good reminder that even advanced tools can make basic mistakes, and code review remains an indispensable part of development. ### The Solution The fix is simple: add the port to the DSN. ```php // Database.php // ... $this->charset = $config['charset']; $this->port = $config['port']; // 1. Read the port from config try { // 2. Add the port to the DSN string $dsn = "mysql:host={$this->host};port={$this->port};dbname={$this->database};charset={$this->charset}"; $this->connection = new PDO($dsn, $this->username, $this->password, ...); // ... ``` --- ## Conclusion and Key Takeaways A two-hour debugging session boiled down to a single missing parameter. This experience offers several valuable lessons: 1. **"Connection refused" Isn't Always a Network Issue**: You'll also get this error when trying to connect to a port that isn't being listened on. It's essentially the TCP layer's `RST` signal. 2. **Verify Your Connection String**: When logging or debugging, always print the final, fully-formed DSN or connection string to ensure all parameters—especially non-default ports—are included correctly. 3. **Test from the Source**: In containerized or complex network environments, testing from within the source environment (the container, the VM) is the most effective way to rule out external factors. 4. **Trust, But Verify**: Whether it's code from a colleague, an open-source library, or an AI, it always warrants a quick review. A tiny oversight can burn an entire afternoon.