---
url: /guide/databases.md
description: >-
  Managed Postgres, MySQL and Redis; extensions, backups, point-in-time restore,
  dump import, roles and row-level security.
---

# Managed databases

```toml
[databases.db]
engine = "postgres"
version = "18"
extensions = ["pgvector"]
resources = { cpu = 1.0, memory = "1G", disk = "20G" }
backup = { schedule = "0 3 * * *", retain = 7 }
```

| Key | Meaning |
|---|---|
| `engine` | `postgres`, `mysql` or `redis` |
| `version` | Major, or major.minor: `"18"`, `"18.2"`. Minimum Postgres 18, MySQL 8, Redis 7. |
| `extensions` | Postgres only: `pgvector`, `postgis`, `pg_cron`, `timescaledb` |
| `resources` | `cpu`, `memory`, `disk` (the data volume quota), as for services |
| `backup.schedule` | Five-field cron expression, UTC |
| `backup.retain` | Number of base backups to keep |

Each database is one container with a persistent volume, reachable inside the
project as `<name>.<project>.internal`. Services get its credentials through
[`from_database`](/guide/environment#database-references). Changing the major
version of an existing database is refused, in both directions.

::: warning Extensions are created once
The listed extensions are created when the database is first initialised. Adding
one to a database that already exists is not applied automatically.
:::

## Connecting

```bash
duploy db creds db               # credentials
duploy db connect db             # psql through a tunnel
duploy db connect db -- -c 'select now()'
duploy tunnel db:db              # local port for your own client
```

The tunnel goes through the control plane; the database is never exposed to
the internet. `db connect` runs `psql`, `mysql` or `redis-cli` from your
`PATH`. Point it elsewhere with `DUPLOY_PSQL`, `DUPLOY_MYSQL` or
`DUPLOY_REDIS_CLI`.

## Backups and restore

| Engine | Backup |
|---|---|
| Postgres | Base backup plus continuous WAL archive: restore to any moment |
| MySQL | `mysqldump` |
| Redis | RDB snapshot |

```bash
duploy db backup db                                  # take one now
duploy db backups db
duploy db restore db --backup <id>
duploy db restore db --at 2026-10-20T14:05:00Z       # Postgres: point in time
duploy db timelines db                               # Postgres timelines after restores
```

A restore happens in place: the database stops, the current data is copied
aside, the backup is restored, and the copy is put back if anything fails.

## Importing a dump

`duploy db import` **replaces** the database with a dump:

```bash
duploy db import db ./dump.sql.gz
```

| Engine | Accepted |
|---|---|
| Postgres | Plain SQL or `pg_dump -Fc`, optionally gzipped |
| MySQL | `mysqldump` SQL, optionally gzipped |
| Redis | `dump.rdb`, optionally gzipped |

Directory-format dumps (`pg_dump -Fd`) are rejected; dumps taken with
`--create` fail. An import uses the same path as a restore, so a failed import
leaves the old data in place.

A Postgres dump is loaded as the `app` user, never as a superuser. Lines that
need a superuser are skipped and counted in the job log rather than failing
the import: comments on extensions, ownership changes (objects end up owned by
`app`), and the rows of extension-owned tables such as `cron.job`. Re-create
`pg_cron` jobs with `cron.schedule` after an import. A dump that needs an
untrusted extension your config does not list fails with
`permission denied to create extension`; add it to `extensions`.

## Postgres roles and row-level security

The `app` user is the owner of the database, but not a superuser:
`NOSUPERUSER NOBYPASSRLS CREATEDB CREATEROLE`. Row-level security policies
apply to it.

A table's owner bypasses RLS unless the table has `FORCE ROW LEVEL SECURITY`.
If your app relies on RLS, let it connect as its own role. `app` can create
one, but since PostgreSQL 16 a role created by a non-superuser does not grant
its creator `SET`. Ask for it in the same session:

```bash
duploy db connect db -- \
  -c "SET createrole_self_grant = 'set'" \
  -c "CREATE ROLE myapp LOGIN PASSWORD '…' NOSUPERUSER NOCREATEROLE" \
  -c "CREATE DATABASE myapp OWNER myapp"
```

Then point the app at it with a secret, for example
`postgres://myapp:…@db.shop.internal:5432/myapp`.
