# External database access

> Expose managed databases to applications outside your private network with the Fleetdock Gateway — HAProxy TCP proxy, CIDR allowlists, and app credentials.

Source: https://fleetdock.dev/docs/external-access


Fleetdock can expose managed databases to applications outside the private network through a
**Fleetdock Gateway** (HAProxy). The control plane generates configuration from persisted
endpoint state; HAProxy proxies TCP traffic with **one public port per database**.

The API never proxies database connections. Desired state lives in `database_endpoints`; HAProxy
config is derived and reconciled by the control-plane worker.

## Architecture [#architecture]

```text
External app → gateway.example.com:15432 (TCP) → HAProxy → server.address:instance.port → Database
```

<Callout type="info">
  The gateway is colocated with the Manager in the Docker Compose stack. The control plane
  writes `haproxy.cfg` and performs a hitless reload through the HAProxy master socket. A
  config HAProxy rejects is rolled back automatically — the running workers keep serving the
  previous version. Reloads only happen when the generated config actually changes.
</Callout>

<Callout type="warn">
  The **gateway container** must have a network route to `server.address:instance.port`. If it
  cannot reach the database, the endpoint reports `error` with the unreachable address.
</Callout>

## Enable the gateway [#enable-the-gateway]

External access is **off by default** — turning it on publishes a 50-port range on the host,
which most installs never need.

```bash
fleetdock gateway enable
```

That flips `FLEETDOCK_GATEWAY_ENABLED`, activates the `gateway` compose profile (HAProxy and
its one-shot init, sharing `/var/lib/fleetdock/gateway` with the control plane), and prints
the port range to open in your firewall. `fleetdock gateway disable` reverses it.

By hand, the equivalent is `FLEETDOCK_GATEWAY_ENABLED=true` in `.env` plus
`docker compose --profile gateway up -d`.

### Hostname [#hostname]

`FLEETDOCK_GATEWAY_PUBLIC_HOST` defaults to your main `FLEETDOCK_DOMAIN`. The gateway speaks
raw TCP on its own ports — no vhost, no SNI, no TLS termination — so it needs no DNS record
of its own.

<Callout type="warn">
  Set it separately when the main record cannot carry raw TCP. The usual culprit
  is a **CDN-proxied record** — Cloudflare's orange cloud forwards 80/443 only,
  so every database endpoint on that name is unreachable. Point a grey-clouded
  `gw.example.com` at the host and set the variable to it.
</Callout>

The hostname is copied into each endpoint when public access is enabled, so changing it
later does not rewrite connection URLs already issued. `fleetdock domain` explains how to
adopt a new one.

### Port range and options [#port-range-and-options]

```bash
FLEETDOCK_GATEWAY_PORT_RANGE_START=15432
FLEETDOCK_GATEWAY_PORT_RANGE_END=15481
```

The published port range and the allocator range come from the same variables. Widen one and
you must widen the other, or endpoints are assigned ports that nothing forwards — a failure
only external clients can see.

Optional:

```bash
FLEETDOCK_GATEWAY_DIAG_PORT=15431          # source-IP diagnostic; 0 disables it
FLEETDOCK_GATEWAY_SOURCE_IP_MODE=direct    # or proxy-protocol, behind an L4 load balancer
FLEETDOCK_GATEWAY_CONFIG_PATH=/var/lib/fleetdock/gateway/haproxy.cfg
FLEETDOCK_GATEWAY_MASTER_SOCKET=/var/lib/fleetdock/gateway/haproxy-master.sock
FLEETDOCK_GATEWAY_ADMIN_SOCKET=/var/lib/fleetdock/gateway/haproxy-admin.sock
```

## Enable public access per database [#enable-public-access-per-database]

On the database page → **Connection** tab → **Enable public access** with allowed CIDRs. Or
use the API:

* `POST /v1/databases/{id}/public-access` — enable with CIDR allowlist
* `PATCH /v1/databases/{id}/public-access` — update CIDRs
* `DELETE /v1/databases/{id}/public-access` — disable

Check status with `GET /v1/databases/{id}/connectivity`.

## Private vs public endpoints [#private-vs-public-endpoints]

| Path        | When to use                                             | Endpoint                                                |
| ----------- | ------------------------------------------------------- | ------------------------------------------------------- |
| **Private** | Apps on the same private network as the database server | `server.address:instance.port` (internal)               |
| **Public**  | Apps outside the private network                        | `FLEETDOCK_GATEWAY_PUBLIC_HOST:assigned-port` (gateway) |

Public access is **disabled by default**. When enabled, the gateway hostname and port stay
stable across migrations — only the private backend target changes. See
[Migration](/docs/migrations#credential-handling-and-endpoints).

## Application credentials [#application-credentials]

Create scoped credentials under **Application credentials** on the database page's
**Connection** tab (or via
`POST /v1/databases/{id}/credentials`). Passwords are encrypted at rest; the connection URL
is shown **once** on create or rotate.

Permission profiles:

| Profile     | Scope                                  |
| ----------- | -------------------------------------- |
| `readonly`  | `SELECT` on the database               |
| `readwrite` | `SELECT`, `INSERT`, `UPDATE`, `DELETE` |
| `admin`     | Full database-scoped privileges        |

<Callout type="warn">
  Instance root credentials are never exposed for public use. Create application credentials
  with least-privilege profiles for external applications.
</Callout>

Rotate or revoke credentials via:

* `POST /v1/databases/{id}/credentials/{credentialId}/rotate`
* `DELETE /v1/databases/{id}/credentials/{credentialId}`

## Source IP fidelity [#source-ip-fidelity]

Allowlists match **the source address HAProxy observes**, which is not always your client's
address. Getting this wrong rejects every connection, and the rejection looks like the server
closing the connection immediately.

| Deployment                                  | Address HAProxy sees        | Allowlists work?                                            |
| ------------------------------------------- | --------------------------- | ----------------------------------------------------------- |
| Linux host, published ports (iptables DNAT) | the real client address     | Yes                                                         |
| Docker Desktop (macOS/Windows)              | the Docker VM's NAT address | **No** — every real client is rejected                      |
| Behind an L4 load balancer                  | the balancer's address      | Only with `FLEETDOCK_GATEWAY_SOURCE_IP_MODE=proxy-protocol` |
| `network_mode: host` on Linux               | the real client address     | Yes, and avoids publishing a large port range               |

Find out what your gateway actually sees. Run this **from the machine that will connect**:

```bash
curl http://gateway.example.com:15431
```

It prints the address to allowlist.

<Callout type="info">
  A bare address is accepted and treated as a single host (`/32` or `/128`). A CIDR with host
  bits set — `10.1.2.3/24` — is rejected rather than silently widened to a network you did not
  ask for.
</Callout>

## Changing the allowlist [#changing-the-allowlist]

Edit it in place from the connectivity panel, or with `PATCH /v1/databases/{id}/public-access`.
**The assigned port is preserved**, so existing clients keep working. Disabling and re-enabling
allocates a different port and breaks every connection URL already handed out.

If the panel reports rejected connections with no successful sessions, the allowlist does not
match where clients are arriving from — check the diagnostic port above.

## What Fleetdock can and cannot verify [#what-fleetdock-can-and-cannot-verify]

| Fact                                                           | How it is determined                                                             |
| -------------------------------------------------------------- | -------------------------------------------------------------------------------- |
| The route is programmed and the gateway can reach the database | HAProxy's own health check, read over the stats socket                           |
| The database accepts TLS                                       | The control plane dials the database **directly** and runs a real protocol probe |
| The published port is reachable from the internet              | **Not determinable** — firewalls, NAT, and DNS sit outside the control plane     |

Endpoint status reflects those facts:

| Status                   | Meaning                                                                                                           |
| ------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| `pending`                | Not yet in the applied gateway config, or the last apply failed — see `last_error`                                |
| `active`                 | In the applied config, and HAProxy reports the database reachable                                                 |
| `error`                  | In the applied config, but the database is down. The listener stays up so a restart does not reallocate the port. |
| `disabling` / `disabled` | Being removed / removed                                                                                           |

## TLS [#tls]

HAProxy uses **TCP passthrough** — it does not terminate TLS at the gateway. Clients negotiate
TLS with the database directly, so the database must support it for `sslmode=require` to work.

`tls_status` is measured against the database itself: a Postgres `SSLRequest` probe, or the
`CLIENT_SSL` capability bit in the MySQL/MariaDB handshake.

<Callout type="warn">
  When `tls_status` is `unsupported` while `tls_mode` is `required`, issued connection URLs
  carry `sslmode=require` and **will fail** until TLS is enabled on the database. Fleetdock does
  not quietly downgrade the requirement, because that would drop encryption you asked for.
</Callout>

## Security [#security]

* Public access is **disabled by default**
* CIDR allowlists are enforced in HAProxy (`tcp-request connection reject unless …`)
* Explicit `0.0.0.0/0` is required for allow-from-anywhere — the UI warns when you choose it
* Instance root credentials are never exposed for public use
* Connectivity and credential changes are recorded in the [audit log](/docs/audit-log)

See [Security](/docs/security) for the full trust model.

## Production [#production]

Open the gateway port range in the host firewall. The control plane cannot verify
reachability from outside — firewalls, NAT and DNS sit beyond it — so this is the step most
often missed. The dashboard and API stay on `443`; gateway ports are separate TCP listeners
on the same host.

On Linux, `network_mode: host` for the gateway service both avoids publishing a large port
range and preserves real client addresses for allowlists.

## Troubleshooting [#troubleshooting]

| Symptom                                             | Check                                                                                                                                            |
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| Endpoint stuck `pending`                            | `last_error` on the endpoint; the `reconcile_gateway` operation; whether the gateway container is healthy                                        |
| Endpoint shows `error`                              | `last_error` names the unreachable `host:port` — can the **gateway container** reach it?                                                         |
| Connection closes immediately                       | The allowlist. Run `curl http://<public-host>:15431` from the connecting machine and compare. The panel also reports rejected-connection counts. |
| `server does not support SSL, but SSL was required` | The database does not accept TLS — enable it, or recreate the endpoint with `tls_mode=preferred`                                                 |
| Cannot connect externally                           | DNS to `FLEETDOCK_GATEWAY_PUBLIC_HOST`, host firewall on the port range, and that the range is actually published                                |
| Connection refused                                  | Gateway service running; port assigned within `PORT_RANGE_START`–`END`                                                                           |

See also [Troubleshooting — Gateway](/docs/troubleshooting#gateway-and-external-access).

## MVP limitations [#mvp-limitations]

* No TLS termination at gateway (passthrough only)
* One public port per database (no hostname/SNI routing)
* The gateway must have a network route to `server.address:port` (same as DB admin today)
* CIDR allowlists depend on the gateway observing real client addresses — see Source IP fidelity above

## API reference [#api-reference]

Gateway and credential endpoints are documented under the **Databases** tag in the
[API reference](/docs/api).
