"Cannot connect to MySQL using SSL" is a hostname problem
Posted in Php, Mysql, Azure, Networking
By Dušan Dželebdžić

A client asked for a small change recently: swap the MySQL hostname in a Symfony app for a new one. They're building an HA setup, so instead of pointing the app at the Azure database directly, it should go through a single stable FQDN, something like mysql.gslb.internal.example.com. One line in .env.local. What could go wrong.
This could:
Doctrine\DBAL\Exception\ConnectionException:
"An exception occurred in the driver:
SQLSTATE[HY000] [2002] Cannot connect to MySQL using SSL"Read that message cold and you'll start debugging SSL configuration. Is the CA bundle missing? Does the server require TLS 1.3? Did someone forget ssl-mode? All reasonable guesses, all wrong.
The error is three exceptions deep
Doctrine wraps the PDO exception, PDO wraps the driver exception, and the driver wraps the thing that actually happened. In the JSON logs, the useful part sits at the bottom of the previous chain:
PDO::connect(): Peer certificate CN=`a1b2c3d4e5f6.database.azure.com'
did not match expected CN=`mysql.gslb.internal.example.com'That's not an SSL failure. The TLS handshake worked fine. The client connected, the server presented its certificate, encryption was negotiated. Then mysqlnd did what any TLS client is supposed to do: it checked whether the certificate matches the hostname it dialed. It doesn't, so the connection gets torn down, and by the time the error bubbles up to Doctrine it has been compressed into "Cannot connect to MySQL using SSL." Technically true. Practically useless.
So the first lesson is boring but real: when Doctrine gives you SQLSTATE 2002, log or dump the full exception chain before touching any config. The bottom exception is usually specific enough to end the investigation on the spot.
Why the alias can't ever verify
The new hostname was a GSLB name. If you haven't run into the term: Global Server Load Balancing is load balancing done in DNS. When the app resolves the name, the GSLB appliance (an F5, typically) decides which real server's address to answer with, based on health checks and site priority. Primary site dies, DNS answers change, failover happens without touching the app. Nice design.
The catch is that a GSLB never sits in the traffic path. It only steers DNS answers. Your app ends up talking directly to the Azure MySQL server, which presents its own managed certificate for its own name, something.database.azure.com. Azure operates that certificate and you can't add your internal alias to it. So the client dials one name, the server proves it owns a different one, and hostname verification fails. Not a bug anywhere in the stack. Every component is doing its job, and the combination still can't work.
This isn't specific to Azure or to GSLB either. Any CNAME, ProxySQL-less DNS failover trick, or hand-rolled alias in front of a managed database with a provider-issued certificate hits the same wall.
The fix, and the part PDO won't give you
The clean fix is to terminate TLS at something that can present a certificate for the alias name: HAProxy or ProxySQL at each site, holding a cert for mysql.gslb.internal.example.com, speaking verified TLS to Azure behind it. That's proper, and it's also new infrastructure someone has to run.
The pragmatic fix is to turn off server certificate verification in the client:
# config/packages/doctrine.yaml
doctrine:
dbal:
url: '%env(resolve:DATABASE_URL)%'
options:
!php/const PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT: '%env(bool:DATABASE_SSL_VERIFY)%'With DATABASE_SSL_VERIFY=true as the default in .env and false only in the .env.local of the environment that needs it. The connection is still encrypted; you've just stopped checking who's on the other end. On a private network path that's a tradeoff plenty of teams accept, but it is a tradeoff, so write it down where the next person will find it. Encryption without verification protects you from passive eavesdropping and nothing else.
Here's the annoying detail I went looking for and didn't find: middle ground. What you actually want is "verify the certificate chain, skip the hostname check." mysqli has a flag for roughly that. pdo_mysql doesn't. With PDO it's full verification or none, so the config above is as fine-grained as it gets without switching drivers.
One more option worth raising before you ship the workaround: ask whether the alias is needed at all. Azure Database for MySQL Flexible Server with zone-redundant HA keeps the same hostname across failovers. If the HA story is "survive a zone outage," Azure already does that under the original name and verification keeps working. The alias only earns its keep for cross-region failover to a separate replica server.
Takeaway
"Cannot connect to MySQL using SSL" means the SSL part already succeeded. The message you need is at the bottom of the exception chain, and in this case it names the real problem precisely: the certificate belongs to the server's real name, and you dialed an alias. DNS-level failover is invisible to TLS, and TLS is deliberately hostile to being lied to about names. Pick which one wins, and make it a documented decision instead of a mystery flag in .env.local.
Wrestling with a managed database that won't accept connections? Send me the details and I'll take a look.