The Ultimate 'Connection Refused' Guide: A PHP PDO & Docker Debugging Saga of a Forgotten Port
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.
Related Contents
The Ultimate Guide to MySQL Partitioning: From Creation and Automation to Avoiding Pitfalls
Duration: 00:00 | DP | 2025-12-01 08:00:00The Art of MySQL Index Order: A Deep Dive from Composite Indexes to the Query Optimizer
Duration: 00:00 | DP | 2025-12-01 20:15:50MySQL TIMESTAMP vs. DATETIME: The Ultimate Showdown on Time Zones, UTC, and Storage
Duration: 00:00 | DP | 2025-12-02 08:31:40Solving the MySQL Docker "Permission Denied" Error on Synology NAS: A Step-by-Step Guide
Duration: 00:00 | DP | 2025-12-03 21:19:10How Can a Docker Container Access the Mac Host? The Ultimate Guide to Connecting to Nginx
Duration: 00:00 | DP | 2025-12-08 23:57:30The Ultimate PHP Guide: How to Correctly Handle and Store Markdown Line Breaks from a Textarea
Duration: 00:00 | DP | 2025-11-20 08:08:00Recommended
The Ultimate CSS Flexbox Guide: Easily Switch Page Header Layouts from Horizontal to Vertical
00:00 | 8This article provides a deep dive into a common CS...
MySQL Primary Key Inversion: Swap 1 to 110 with Just Two Lines of SQL
00:00 | 8In database management, you might face the unique ...
Decoding `realpath: command not found` and Its Chained Errors on macOS
00:00 | 12Encountering the `realpath: command not found` err...
getElementById vs. querySelector: Which One Should You Use? A Deep Dive into JavaScript DOM Selectors
00:00 | 11When manipulating the DOM in JavaScript, both getE...