Back to blog

Connect a Docker Container to Host PostgreSQL Safely

Connect a Dockerized app to PostgreSQL running on the host without relying on localhost, broad HBA rules, or leaked sandbox credentials.

PostgresDockerDocker Composedatabase connectionscoding agents

To connect a Docker container to PostgreSQL running on the host, use host.docker.internal instead of localhost. Docker Desktop provides that hostname. On native Linux Docker Engine, map it with host.docker.internal:host-gateway and verify that PostgreSQL listens beyond loopback; the name mapping alone cannot reach a server bound only to 127.0.0.1. Then confirm that pg_hba.conf allows the actual client source address.

Do not treat the hostname swap as the entire fix. A resolved name proves only that the container can find an address. It does not prove that PostgreSQL is listening there, that a firewall allows the route, that HBA rules match, or that the role can connect to the intended database.

PGSandbox supports disposable database workflows with a Docker-oriented localContainer connection variant. The important decision is where the database client runs, not where the docker compose command starts.

Docker-to-host PostgreSQL at a glance

Database client locationHost in the PostgreSQL URLDocker configurationPGSandbox mode
Host-native commandlocalhost or 127.0.0.1Nonedirect
Docker Desktop containerhost.docker.internalBuilt into Docker Desktop on its supported platformslocalContainer
Native Linux bridge containerhost.docker.internalAdd host.docker.internal:host-gateway; verify the PostgreSQL listener and HBA pathlocalContainer only when the loopback profile is actually reachable through that path
Container reaching a routable database hostThe real DNS name or IPNormal container networkingdirect
Native Linux container using host networkinglocalhostnetwork_mode: host; weaker isolation and not portabledirect

The table separates two ideas that are easy to mix up. localContainer means “a local application container reaching PostgreSQL on the host.” It does not mean PostgreSQL itself runs in a container.

This guide covers container to host PostgreSQL. If PostgreSQL runs in another Compose service, use that service name on the shared Docker network. If the application runs on the host and PostgreSQL runs in a container, publish the database container’s port and connect from the host. Those are different network directions with different addresses.

PGSandbox quick path

Use this six-step path when the application process runs in Docker and the selected PGSandbox profile runs PostgreSQL on the same host:

  1. Identify the runner: confirm that the database client runs inside the application container.

  2. Call get_connection_string with the sandbox id and an explicit credential request:

    {
      "databaseId": "sandbox-id",
      "includeCredentials": true
    }
  3. Select connectionStrings.localContainer from the result. On native Linux Docker Engine, add the host-gateway mapping and verify that the PostgreSQL listener is reachable from the bridge.

  4. Inject the returned URL unchanged into the application service at runtime. Do not reconstruct it or assume port 5432.

  5. Run the application check or migration from the container, then verify database effects with bounded SQL or a schema diff.

  6. Delete the sandbox when the proof is complete, or let its TTL cleanup policy remove it.

The credential-bearing result is sensitive. Prefer run_repo_command injection when PGSandbox can launch the required command without returning the raw URL to an intermediate workflow.

Why localhost fails inside a Docker container

With normal bridge networking, a container has its own network namespace and loopback interface. Docker’s networking overview explains that 127.0.0.1 inside a container refers to that container’s loopback address.

That makes this URL wrong when PostgreSQL runs on the host:

postgresql://sandbox_role:password@localhost:5432/sandbox_database

The application asks for port 5432 inside its own container. Unless another process in that same container is listening there, the connection fails.

The conventional host alias is host.docker.internal. Docker Desktop documents it as the hostname for reaching a service on the host from a container. Docker Engine documents the special host-gateway value, which can map the same name on native Linux.

Use this Compose shape:

services:
  app:
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      DATABASE_URL: ${SANDBOX_DATABASE_URL}

Set SANDBOX_DATABASE_URL in the process that invokes Compose. Do not put a real credential-bearing URL in a tracked Compose file. The extra_hosts entry is redundant on Docker Desktop but useful when the same file also runs on native Linux Docker Engine.

The mapping solves name resolution. It does not open PostgreSQL to that address.

A credential-bearing URL has this shape:

postgresql://<role>:<password>@host.docker.internal:<returned-port>/<database>

Use the complete URL returned by PGSandbox. Do not reconstruct it or assume port 5432.

The raw docker run equivalent is:

docker run --rm \
  --add-host host.docker.internal:host-gateway \
  -e DATABASE_URL="$SANDBOX_DATABASE_URL" \
  your-app-image

The variable should contain the real sandbox port. Do not rebuild the URL around an assumed port 5432.

Check three boundaries in order

A Docker-to-host PostgreSQL connection crosses three independent boundaries. Diagnose them in order so a routing problem does not turn into an overly broad database rule.

1. Container name and route

The container must resolve host.docker.internal to a host-side address and route traffic to it. On native Linux, Docker’s host-gateway normally resolves to an address on the host side of the default bridge, though a daemon administrator can override it.

Check from the same service container that will run the application:

getent hosts host.docker.internal

Then test the PostgreSQL port with a tool already present in the image:

nc -vz host.docker.internal <returned-port>

A successful hostname lookup is not a successful PostgreSQL connection. It only clears the first boundary.

2. PostgreSQL listener and host firewall

PostgreSQL’s default listen_addresses value is localhost. That accepts loopback TCP connections but may not accept traffic sent to a Linux bridge gateway address.

Inspect the effective settings on the host:

SHOW listen_addresses;
SHOW port;
SHOW hba_file;

On Linux, confirm the bound addresses with ss -ltn. If PostgreSQL must listen beyond loopback, add only the host interface the container actually reaches when practical:

listen_addresses = 'localhost,<reachable-host-interface>'

Changing listen_addresses requires a PostgreSQL restart. Avoid listen_addresses = '*' as a troubleshooting shortcut. It turns a narrow local problem into a service exposed on every available interface unless other controls happen to block it.

The host firewall is a separate check. A timeout after successful name resolution often points to routing or packet filtering. A quick 0.0.0.0/0 firewall rule is not a safe diagnostic step on a laptop, shared development server, or agent host.

3. HBA rule, role, and database

Once TCP reaches PostgreSQL, pg_hba.conf decides whether the client address, database, role, and authentication method match an allowed record. PostgreSQL’s HBA documentation states that records are considered in order and the first matching record is used. If authentication fails under that record, PostgreSQL does not fall through to later records.

Scope a rule to the sandbox database, sandbox role, and actual Docker source CIDR:

host    <sandbox_database>    <sandbox_role>    <actual-docker-cidr>    scram-sha-256

<actual-docker-cidr> is deliberately a placeholder. Inspect the Compose network as a candidate instead of copying 172.17.0.0/16 from an old answer:

docker network inspect <compose-network>

Docker networks can use different subnets, and Desktop or NAT paths may present a different source address to PostgreSQL. Confirm the server-observed source address from a controlled connection attempt, the PostgreSQL log, or a no pg_hba.conf entry error before finalizing the CIDR. The destination alias host.docker.internal does not belong in an HBA rule merely because the client used that name.

Use scram-sha-256 where the application driver supports it. PostgreSQL describes SCRAM as its strongest currently provided password authentication method, while MD5-encrypted passwords are deprecated. Do not use trust for a Docker subnet. A matching trust rule lets any reachable client authenticate as a permitted database role without a password.

After editing pg_hba.conf, reload with SELECT pg_reload_conf() or your normal service command. Validate the parsed rules before widening them:

SELECT line_number,
       type,
       database,
       user_name,
       address,
       auth_method,
       error
FROM pg_hba_file_rules
ORDER BY line_number;

Rows with a non-null error identify malformed or ineffective rules. Access to this view may itself require an appropriately privileged operator role.

Use the PGSandbox connection variant that matches the client

PGSandbox derives two URL variants for a loopback sandbox profile:

  • direct preserves the original host, such as localhost or 127.0.0.1.
  • localContainer rewrites only that loopback host to host.docker.internal. The scheme, role, password, port, database, and query parameters remain the same.

Use the returned variant as a unit. Do not copy the role and database name into a new URL or replace its port with 5432.

Creation tools return redacted variants for safe summaries. The get_connection_string contract also returns redacted values by default and includes credential-bearing values only when includeCredentials: true is requested. A non-loopback profile does not receive a localContainer variant because its original routable hostname is already the correct destination.

Call run_repo_command with the direct URL mode for a host-native migration command:

{
  "repoPath": "/absolute/path/to/repo",
  "databaseId": "sandbox-id",
  "command": ["npm", "run", "migrate"],
  "connectionMode": "direct"
}

Call run_repo_command with the container variant when the database client runs inside the Compose service:

{
  "repoPath": "/absolute/path/to/repo",
  "databaseId": "sandbox-id",
  "command": ["docker", "compose", "run", "--rm", "web", "python", "manage.py", "migrate"],
  "connectionMode": "localContainer",
  "databaseUrlEnvNames": ["SANDBOX_DATABASE_URL"],
  "tailLines": 80,
  "suppressDockerLifecycle": true
}

The repo workflow tool contract injects DATABASE_URL, PGSANDBOX_DATABASE_URL, libpq PG* variables, and validated URL aliases into the child process. Compose still needs to pass or interpolate the chosen variable into the application service, as the earlier YAML example does. Starting docker compose on the host does not make localhost correct inside the service container.

PGSandbox rejects connectionMode: "localContainer" for a non-loopback profile with local_container_unavailable; use direct for that profile. It also executes direct argument arrays instead of shell strings, bounds returned command output, and masks exact injected URL and password values from captured output.

Masking is a defense against accidental output, not permission to print credentials. A child process can transform, split, persist, or transmit a secret in ways exact-token masking cannot catch. Keep raw URLs out of chat, logs, PR comments, issues, screenshots, and tracked files.

Native Linux needs one extra reachability check

On native Linux bridge networking, extra_hosts: host.docker.internal:host-gateway maps a name to the host bridge gateway. It does not make a PostgreSQL server bound only to 127.0.0.1 listen on that gateway.

This matters for PGSandbox’s current managed-local runtime. It binds its managed PostgreSQL profile to loopback. The localContainer variant can therefore produce the right Docker destination name while the listener remains unreachable from a native Linux bridge container.

Do not repair that mismatch by automatically exposing the managed cluster on every interface. Choose an explicit path:

  1. Run the repo command on the host with connectionMode: "direct" when container execution is not required.
  2. Use Docker Desktop and verify the host-forwarding path from the actual service container.
  3. Configure a deliberate external PGSandbox profile whose PostgreSQL listener, firewall, HBA rules, and data policy allow the required Docker network.
  4. Use Linux host networking only in a controlled development environment after accepting its weaker network isolation and platform differences.

The PGSandbox architecture keeps managed-local and external profiles explicit. A routable external profile is not automatically safer; it is a different network contract that needs its own narrow access rules.

Do not confuse authentication with transport security

SCRAM authenticates the role. It does not encrypt all PostgreSQL traffic. TLS is a separate server and client configuration.

If the profile requires sslmode=verify-full, the hostname in the connection URL must match the server certificate. Rewriting a URL from localhost to host.docker.internal can fail certificate hostname verification when the certificate does not include that DNS name. PostgreSQL’s libpq SSL documentation recommends verify-full in security-sensitive environments because it verifies both the certificate chain and the requested hostname.

Do not downgrade TLS verification to make a Docker alias work. Use a certificate with the intended name, use the profile’s real routable name, or keep the command on the host when that preserves the verified connection contract.

Troubleshoot by the error boundary

SymptomBoundary most likely failingNext check
host.docker.internal does not resolveContainer name mappingDocker Desktop support or Linux extra_hosts
Connection refusedListener address or portSHOW listen_addresses, SHOW port, host sockets
Connection timeoutRoute, firewall, VPN, or endpoint securityTCP path from the same service container
no pg_hba.conf entryHBA database/user/source/encryption matchActual source address, rule order, pg_hba_file_rules
Password authentication failedRole, password, or auth-method compatibilitySandbox credential and first matching HBA rule
Certificate hostname mismatchTLS identity verificationCertificate SAN and URL hostname
PGSandbox local_container_unavailableProfile is not loopbackUse the profile’s direct URL
Database or role does not existURL was rebuilt or points to stale sandbox stateUse the complete current URL returned for the sandbox id

Test from the same network namespace as the application. A host-native psql success does not prove the container path, and a successful ping does not prove TCP, HBA, or role authentication. If the image includes PostgreSQL client tools, use:

pg_isready -h host.docker.internal -p <returned-port>

Then run the application’s real database check. A generic port probe can clear the network boundary, but only the real driver exercises URL parsing, TLS, authentication, and database selection together. When psql is available in the same container, a small identity check is useful:

psql "$DATABASE_URL" -c 'select current_database(), current_user;'

Capture evidence after the command

An exit code of zero proves that a command completed. It does not prove which schema objects or rows changed.

After a Dockerized migration or seed command, use schema_digest or schema snapshots to record the database shape. Use run_sql with bounded results for small data assertions. When the command is a migration, validate_schema_change can capture the before and after schema contract.

This is the final part of a safe agent workflow. Network reachability gets the command to the sandbox. Scoped role credentials limit its authority. Evidence shows what it actually did. The database migration testing guide shows how to turn that evidence into a reviewable PR note.

The Runner-Network-Secret-Evidence checklist

Use these four checks before letting a coding agent run a Dockerized database command:

  1. Runner: Identify where the database client process runs. A host-started Docker CLI can launch a client inside a container.
  2. Network: Choose direct for host-native clients and routable profiles. Choose localContainer only for a local container reaching a loopback profile through a verified host path.
  3. Secret: Inject the task-scoped sandbox URL at runtime. Do not bake it into an image, tracked Compose file, build argument, log, or PR note.
  4. Evidence: Treat command output as execution evidence, then inspect database effects with a bounded query, schema digest, snapshot, or schema-change validation.

This framework is more durable than memorizing a Docker hostname. It forces the workflow to account for the process boundary, network boundary, credential boundary, and proof boundary independently.

Frequently asked questions

What hostname should a Docker container use for PostgreSQL on the host?

Use host.docker.internal. Docker Desktop provides it automatically. On native Linux Docker Engine, add host.docker.internal:host-gateway through extra_hosts or --add-host. Then verify that PostgreSQL listens on the resolved host interface and that HBA and firewall rules allow the container’s actual source address.

Why does host.docker.internal resolve but PostgreSQL still refuses the connection?

Name resolution is only the first boundary. PostgreSQL may still listen only on loopback, the host firewall may block the port, or the first matching pg_hba.conf record may reject the container’s source address, database, role, or encryption mode. Test routing, listener, HBA, and authentication separately.

Should I put host.docker.internal in pg_hba.conf?

Usually no. The URL hostname is the destination the client dials. HBA address rules match the client source address PostgreSQL sees. Inspect the actual Docker network and use the narrow source CIDR or address required for that sandbox role and database.

Does PGSandbox expose raw database URLs by default?

No. Creation tools and get_connection_string return redacted direct and container variants by default. Raw credential-bearing variants require an explicit includeCredentials: true request. Prefer run_repo_command runtime injection when the tool can launch the required command directly.

Does a successful Dockerized migration prove the database change is correct?

No. Exit zero proves command completion. Capture a schema digest or before-and-after diff, run bounded data assertions, and record cleanup. Database evidence is a separate part of the workflow.