Skip to content

Connecting Studio to PostgreSQL running in Docker

The container-to-container case is the one that breaks: when Studio is itself a container, localhost is its own loopback and the service name is the fix.

Three facts are enough to connect to Postgres running in Docker: a host, a port and a database name. Getting the fixture up takes one command. What costs an afternoon is the second container - the one Studio itself is running in, where localhost means something different than it did on your laptop.

A connection is a host, a port and a database, and nothing else

The PostgreSQL provider is built on pg (node-postgres), and its getCapabilities() declares defaultPort: 5432 and supportsConnectionString: true. Two forms of configuration are accepted, and they are not additive.

Discrete fields. validate() requires host and database; port, user and password fill in the rest. That is the whole form.

A connection string. postgres:// and postgresql:// both parse, and supplying one bypasses the host/database requirement entirely.

The trap in between is worth knowing before you fight it: validate() does not reject supplying both. If a connection carries a string and discrete fields, buildPoolConfig() uses the string and ignores the fields. Editing the host box on a connection you originally pasted a URL into changes nothing.

Behind the form, connect() builds a pg.Pool and validates it by acquiring and releasing one client. The pool defaults are min 2, max 10, a 30-second idle timeout and a 60-second acquire timeout; the statement timeout is separate, defaults to 60 seconds, and is applied as the pool’s statement_timeout. None of that changes between a local fixture and a managed instance. What changes is only ever the host.

The one-command fixture: connect to Postgres running in Docker

This is the recipe in the provider doc’s own smoke-test section:

docker run --rm -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:18

Then point a connection at host localhost, port 5432, database postgres, user postgres, password postgres. The E2E suite is verified against PostgreSQL 18.x, so this is the same server the tests run against.

The repository keeps a longer-lived version of the same thing. database-compose.yml defines a postgres service on postgres:18 with POSTGRES_USER, POSTGRES_PASSWORD and POSTGRES_DB all set to postgres, published on 5432:

docker compose -f database-compose.yml up -d postgres

If Studio is running on your machine rather than in a container - the npx package, the desktop build, a dev server - you are done. The get started walkthrough picks up from the connection dialog. If Studio is a container, read on, because the next thing you will see is a connection error.

Why localhost fails from inside a container

-p 5432:5432 publishes the database’s port on the Docker host. It does not put anything on port 5432 inside any other container.

So when Studio runs as a container and you type localhost, the driver resolves it inside Studio’s own network namespace and dials Studio’s own loopback interface. Nothing is listening there. The connection is refused before a single byte of the PostgreSQL protocol is exchanged, and connect() fails with a ConnectionError carrying the host and port it tried.

That last detail is the fastest way to tell this failure apart from the ones it gets confused with. pg errors are normalised by mapDatabaseError() into distinct classes: bad password gives you an AuthenticationError, an exhausted pool or a server refusing further connections gives you PoolExhaustedError, a bad statement gives you QueryError. A ConnectionError on localhost:5432 means the driver never reached a server at all. There is no credential to fix and no pg_hba.conf line to add - the packet had nowhere to go.

The same reasoning covers the other two shapes of this. 127.0.0.1 is the same loopback under a different spelling. And a database on the host machine rather than in a container is not reachable at localhost from inside Studio either; that case needs whatever address your Docker installation gives the host, which is a property of your Docker setup and not of this application.

Joining the network and using the service name

Put both containers on one network and address the database by its service name. Compose gives you the network and the DNS for free:

services:
  postgres:
    image: postgres:18
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: postgres

  studio:
    image: ghcr.io/libredb/libredb-studio:latest
    ports:
      - '3000:3000'
    depends_on:
      - postgres

Open Studio on port 3000 and fill the connection in with host postgres, port 5432, database postgres, user postgres, password postgres. As a string, the repetition looks like a typo and is correct:

postgresql://postgres:postgres@postgres:5432/postgres

Two things about that port. It is the container port, not a published mapping - 5432 is where the server listens inside its own container, and the ports: block above is absent from the database service on purpose, because container-to-container traffic never goes through it. Keep ports: only if you also want a client on your laptop to reach the database.

If the database is already running from database-compose.yml and you would rather not merge the two files, attach Studio to the network that compose already created:

docker network ls                       # find the project's default network
docker run --network <project>_default -p 3000:3000 ghcr.io/libredb/libredb-studio:latest

One quiet consequence of using a service name: SSL stays off. shouldEnableSSL() auto-enables TLS when the host matches a managed provider (supabase, neon, render, planetscale, aws, azure, gcp, cloud), and postgres matches none of them. That is the right outcome for a fixture on a private bridge network, and it is worth knowing in the other direction too: when the heuristic does fire it uses rejectUnauthorized: false, so the connection is encrypted but the server certificate is not verified. Verified TLS to a managed host needs an explicit SSL mode of verify-system, verify-ca or verify-full.

The same reasoning scales past one machine. Among the deployment channels, a Helm release addresses the database by its Kubernetes service name for the same reason a compose stack addresses it by its compose service name. Only a client on your own machine ever addresses localhost.

What the schema tree shows you first, and how exact those numbers are

The tree loads in two calls, on purpose. getSchemaList() returns tables, columns, primary keys, row counts and sizes, and returns indexes: [] and foreignKeys: []; getSchemaRelations() returns the foreign keys and indexes separately and the client merges them in. So the table list renders before the relationships exist, and the ER diagram’s edges appear a moment after the tables do. A slow relationship query cannot block the tree.

Every introspection CTE is declared AS MATERIALIZED. PostgreSQL 12+ inlines single-reference CTEs, which lets the planner re-execute an information_schema CTE inside a nested-loop join; on a 122-table schema, forcing materialisation moved the introspection from about 295 seconds to about 2.6. System schemas (pg_catalog, information_schema, pg_toast) are excluded and only base tables are listed. Sizes come from pg_total_relation_size.

Now the numbers, because two of them are not what they look like. Table row counts come from pg_class.reltuples, a planner estimate that is stale or -1 until the table is analyzed, and column lists stop at the first 100 columns per table.

The row count beside each table is an estimate, not a count. It reads pg_class.reltuples, which is a planner statistic maintained by ANALYZE and autovacuum, not a COUNT(*). On a table that has never been analyzed reltuples is -1, which the provider clamps to 0. A table you loaded ten seconds ago will therefore read as empty, and a table that has churned since its last analyze will read stale. Run ANALYZE against it and the number corrects itself. The maintenance toolkit that runs VACUUM ANALYZE and ANALYZE for you is admin-only; the statement itself is one you can type in the editor.

Column lists stop at the first 100 columns per table. The introspection query carries ordinal_position <= 100. A wide table renders its first hundred columns and says nothing about the rest. Queries against those columns work normally - the cap is on the tree, not on the engine.

Both are trades made in the same direction: an exact count on every table in a large schema means a sequential scan per table at every tree refresh. The estimate is free, because the planner was keeping it anyway. What the estimate cannot answer is how many rows are in the table right now; for that, type the COUNT(*) in the editor. The engine grid carries the same kind of line for every other engine.