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
Resolving PHP "could not find driver" Error: Ultimate Guide to Missing PDO Database Drivers
Duration: 00:00 | DP | 2026-07-04 08:03:00VS Code PHP Guide: How to Trace Function Definitions Like PHPStorm
Duration: 00:00 | DP | 2026-07-04 20:27:00MySQL Practical Guide: Elegantly Adding Preference Columns to a User Table
Duration: 00:00 | DP | 2026-07-05 08:28:45Resolving Nginx Permission Denied (13) Errors for WebP Images Generated by PHP Imagick
Duration: 00:00 | DP | 2026-07-05 21:17:00How to Fix Mac mini Not Receiving SMS Messages and iCloud Sync Stuck
Duration: 00:00 | DP | 2026-07-06 22:07:00Complete Guide to Installing and Configuring Git Server on Synology NAS: Basic to Advanced
Duration: 00:00 | DP | 2026-07-16 20:39:02Why Do .smbdelete Hidden Files Appear After Deleting on Mac SMB Shares? Causes & Ultimate Solutions
Duration: 00:00 | DP | 2026-06-27 19:10:00Complete Guide to Setting Docker Container Timezone to UTC+8 (Asia/Shanghai)
Duration: 00:00 | DP | 2026-06-30 20:43:30Fixing Nginx 500 Error: Internal Redirection Cycle (SPA vs PHP Config)
Duration: 00:00 | DP | 2026-07-02 21:45:50Ultimate Guide to Fixing Nginx [warn] conflicting server name Warning
Duration: 00:00 | DP | 2026-07-21 09:02:28Fixing 'Unable to locate package openjdk-17-jdk' in PHP 8 Docker (Debian Trixie)
Duration: 00:00 | DP | 2026-07-25 09:23:18Docker Compose Advanced: Configuring Static IPs and Cross-Container SOCKS5 Proxies
Duration: 00:00 | DP | 2026-07-26 09:28:30Nginx Reverse Proxy Guide: Elegantly Routing Specific Subdirectories to Docker Containers
Duration: 00:00 | DP | 2026-07-26 21:31:06DevOps Practice: How to Safely Clear Logs of a Running Docker Container?
Duration: 00:00 | DP | 2026-07-27 09:33:42Ultimate Guide to Setting Up Proxies on CentOS: Global Configuration and Troubleshooting
Duration: 00:00 | DP | 2026-07-27 21:36:19Practical Guide: Translating Complex Docker Compose to Docker Run Commands
Duration: 00:00 | DP | 2026-07-29 09:44:07MySQL TIMESTAMP vs. DATETIME: The Ultimate Guide from DDL Change to Architectural Choice
Duration: 00:00 | DP | 2026-07-31 21:57:08Unlocking the MySQL Self-Referencing FK Trap: Why Does ON UPDATE CASCADE Fail?
Duration: 00:00 | DP | 2026-01-02 08:00:00Recommended
One Handler, Multiple Events: An Efficient Guide to Binding Multiple Event Listeners in JavaScript
00:00 | 4Tired of duplicating code to handle different even...
Composer Script Not Running? Unveiling the `post-install-cmd` Trap and the Ultimate Solution
00:00 | 114Have you ever run `composer install` only to find ...
What is the \uXXXX in API Responses? Understanding Unicode Escape Sequences
00:00 | 105Have you ever encountered mysterious strings like ...
One-Click Code Cleanup: The Ultimate Guide to PhpStorm's Reformat Code Shortcut
00:00 | 121Still manually adjusting code formatting? This art...