Connecting to MySQL in a container, and what the pool honours
Port 3306 with the fixture credentials, the container-network case, and the pool settings that are quietly ignored because the driver model differs.
Two things go wrong when people connect to MySQL in Docker for the first time. The
first is the host: localhost means the container it is typed in, not the machine
running it. The second is slower and quieter - a pool tuned with four settings
copied from advice written for a different driver, three of which this provider
never reads.
The fixture, the port and the credentials
MySQL reaches LibreDB Studio through MySQLProvider, built on mysql2/promise,
extending the same SQLBaseProvider the PostgreSQL provider extends. Default port
3306. Connection strings are supported and passed to the pool as its uri option.
The studio repository ships a fixture so there is nothing to guess. The mysql
service in database-compose.yml:
mysql:
image: mysql:latest
container_name: libredb-mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: mysql
ports:
- "3306:3306"
Start it on its own:
docker compose -f database-compose.yml up -d mysql
Then, in the connection dialog: host localhost, port 3306, user root,
password root, database mysql. That is the whole recipe. If you would rather
not clone the fixture, the standalone equivalent is one line:
docker run --rm -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=app \
-p 3306:3306 mysql:8
and the connection is then localhost:3306, database app, user root.
One scope note before you go looking for a schema tree that is not there. Every
introspection query binds TABLE_SCHEMA = ? to the connected database, so a
connection sees one database and table names render bare, without a
schema.table prefix. To read a second database you make a second connection.
When localhost is the wrong host
The recipe above assumes Studio is running on your machine - bun dev, or a
desktop build - and MySQL is the only thing in a container. Publishing 3306:3306
puts the server on the host’s loopback, and localhost resolves to the same
place your browser does.
Run Studio itself as a container and that stops being true. localhost inside
the Studio container is the Studio container. Nothing is listening on 3306 there,
and the driver returns ECONNREFUSED, which the error mapper turns into a
ConnectionError carrying the host and port it tried. The message is accurate
and looks like a server problem. It is a name problem.
The fix is to put both containers on one network and address the database by its service name:
docker run -p 3000:3000 --network libredb-studio_default \
ghcr.io/libredb/libredb-studio:latest
Then connect to host mysql, port 3306 - the compose service name, on the
compose network, at the container port rather than the published one. The
published 3306:3306 mapping is irrelevant here; it exists for traffic arriving
from the host. Two containers on the same user-defined network reach each other
by service name whether or not anything is published at all.
Every provider funnels its driver’s errors through the same shared
mapDatabaseError(), so this mistake reads the same whichever engine you point
at, which is why it is worth naming once. The engine pages print
each engine’s transport and default port next to its name for exactly this
moment.
Which pool settings are mapped, and which are dropped
connect() builds a mysql2 pool and validates it by acquiring and releasing one
connection. The pool is configured like this:
| mysql2 option | Value | Where it comes from |
|---|---|---|
connectionLimit |
pool max, default 10 |
ProviderOptions.pool.max |
waitForConnections |
true |
fixed |
queueLimit |
0, an unbounded queue |
fixed |
enableKeepAlive |
true |
fixed |
keepAliveInitialDelay |
10000 ms |
fixed |
timezone |
ProviderOptions.timezone ?? 'Z' |
discrete-fields form only |
Only max is honoured as the driver connection limit; min, idleTimeout and
acquireTimeout are ignored, and this provider exposes no pool statistics at
all. The reason the provider documents is that the mysql2 pool model differs
from pg’s; those three options are not passed through, and nothing above the
driver emulates them. Set min to 4 and nothing keeps four connections warm. Set
acquireTimeout and nothing times an acquisition out at that boundary. A
connection-acquire failure still surfaces - the shared error
mapper reads timeout or timed out in the driver’s own message and raises
TimeoutError - but it arrives on the driver’s schedule, not on the number you
typed.
queryTimeout is in the same category and worth stating separately, because its
absence has teeth. The provider configures no server-side statement timeout, so a
runaway query is not killed for you. Cancellation is explicit: a statement issued
with a queryId records its connection’s threadId, and cancelQuery() issues
KILL QUERY <threadId>, which reaches the caller as QueryCancelledError. That
call returns true when the KILL succeeded, without confirming the target was
still executing.
One more trap in the same function. When you supply a connection string,
buildPoolConfig() returns the base config plus uri and skips the
discrete-fields branch entirely. timezone, connection.ssl and cloud SSL
auto-detection are therefore ignored on that path. If you paste a URI, encode
those in the URI.
Why there are no client-side pool numbers here
The PostgreSQL provider implements getPoolStats(), which exposes live total,
idle, active and waiting counts for its own pool. The MySQL provider
implements no getPoolStats(), so there is no client-side pool reading to render
and none is invented.
That is the general rule of the monitoring surface, applied here. Where MySQL’s
performance_schema is off, getPerformanceMetrics() omits every field rather
than defaulting it - cache-hit ratio, queries per second and buffer-pool usage are
reported as absent, not as zero - and where the performance_schema database is
missing outright the method answers an empty object. A zero is a measurement. An
absence is not, and printing one as the other is how a dashboard starts lying.
So the honest answer to “how loaded is my pool” on MySQL is: ask the server, not
the client. information_schema.PROCESSLIST backs the active-sessions panel, and
SHOW STATUS backs the overview. Those are the server’s own numbers about the
connections it is holding, which is the question you were really asking.
Checking the connection before you trust it
Test Connection runs getHealth(), and getHealth() is a real read rather than a
socket check: SHOW STATUS, information_schema.TABLES and PROCESSLIST, and
the performance_schema digest table for the slow-query list. A green badge
therefore means the account can read the catalog, not merely that TCP opened.
The failures separate cleanly, which is the point of testing before you work:
| What you see | What it means |
|---|---|
ConnectionError with host and port |
Nothing answered there - usually the localhost case above |
AuthenticationError |
The server answered and refused the credentials |
DatabaseConfigError |
host and database are required when no connection string is given |
QueryError carrying the server’s message |
Connected and authenticated; the statement is the problem |
Two last expectations to set while you are looking at a fresh connection. Row
counts in the browser come from information_schema.TABLES.TABLE_ROWS, which is
an InnoDB estimate rather than an exact count, and index scans is CARDINALITY,
an estimate of distinct values rather than a usage counter, because MySQL
publishes no equivalent of pg_stat_user_indexes.idx_scan. Both are useful for
ordering things by size. Neither is a number to quote in a report.
Once the badge is green, the setup guide covers the rest of the first run.