---
title: "Databases in pull request previews"
description: "Run PostgreSQL as a Compose service in each stagelet. See where its data lives, when the migration command runs, and how passwords reach it."
url: https://stagely.dev/docs/guides/databases
---

Stagely does not create a managed database for a pull request. The database runs as one more service of your Compose project, inside the stagelet: a private, running copy of the app on its own machine in your cloud account. So each stagelet has its own database. This guide uses PostgreSQL. Another database with a public image follows the same steps.

## Add the database to the Compose project

Use a public image, a named volume for the data, and a health check:

```yaml
services:
  web:
    build: .
    environment:
      DATABASE_URL: postgres://shop:shop@db:5432/shop
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:17
    environment:
      POSTGRES_USER: shop
      POSTGRES_PASSWORD: shop
      POSTGRES_DB: shop
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD", "pg_isready", "--username=shop", "--dbname=shop"]
      interval: 5s
      timeout: 5s
      retries: 10

volumes:
  pgdata:
```

Then name the public service and the migration command in `.stagely.yml`:

```yaml
version: 1
compose:
  - compose.yml
public:
  service: web
  port: 8080
health:
  path: /healthz
migration:
  command: /app/migrate up
```

Replace `/app/migrate up` with the command of your migration tool, such as `bin/rails db:migrate`. The gateway serves only the public service, so people reach the app and not the database. For PostgreSQL 18 and later images, mount the volume at `/var/lib/postgresql` instead, as the [postgres image docs](https://hub.docker.com/_/postgres) say.

A development Compose file often bind-mounts files, such as `./init.sql`. The [Compose rules](https://stagely.dev/docs/app/compose.md) refuse bind mounts. Keep that file for development, and name a second Compose file in `.stagely.yml`, as [Pull request previews for Docker Compose apps](https://stagely.dev/docs/guides/docker-compose.md) shows.

## Where the data lives

The named volume `pgdata` is on the disk of the stagelet's machine. Stagely allows only the `local` volume driver.

| Event                                                    | The data                                                       |
| -------------------------------------------------------- | -------------------------------------------------------------- |
| A new deploy of the same pull request                    | Stays. The agent starts the project again on the same machine. |
| **Reset**                                                | Stays. The agent recreates the containers, not the volumes.    |
| The pull request closes                                  | Gone. The agent deletes the machine with its disk.             |
| The TTL ends: 72 hours after the last deploy, by default | Gone                                                           |
| **Delete** in the console                                | Gone                                                           |

When the pull request deploys again after its stagelet ended, such as after it reopens, Stagely creates a new stagelet: a new machine, an empty volume and a new URL.

## When the migration command runs

After the build, each deploy runs these steps on the stagelet's machine, in this order:

1. **Start services**: the customer agent runs `docker compose up --wait`. Compose starts `db`, waits until its health check passes, then starts `web` ([startup order](https://docs.docker.com/compose/how-tos/startup-order/)). The agent waits up to 10 minutes for every service to run, and for every service with a health check to be healthy.
2. **Migrate**: the agent runs the command with `sh -c` in the running `web` container, through `docker compose exec` ([the code](https://github.com/stagely-dev/stagely/blob/v0.1.0/internal/agent/runtime/runtime.go#L242)).
3. **Health check**: the agent requests `health.path` until the app answers with a 2xx status, for up to two minutes.

This order has consequences for your app:

- The app starts before the migration runs. On the first deploy, the tables do not exist yet. The app must start, and keep running, without them.
- A Compose `healthcheck` on `web` runs before the migration, so it must not need the tables. The `health.path` check runs after the migration, so it can need them.
- The command runs in the app's container. It gets the same environment variables as the app, with every config value and secret. The image of the public service must contain `sh`.
- Every deploy runs the command again, on the same data. Write migrations that change nothing on a second run.
- The output goes to the log of the **Migrate** step in the console.
- A command that exits with a status other than 0 fails the deploy. The containers of the new commit already run at that point. **Reset** brings back the images and config of the last successful deploy. A reset runs no migration, and it does not undo what a migration changed.

## Seed data

Stagely has no seed step. To seed, add your seed command to the migration command:

```yaml
migration:
  command: /app/migrate up && /app/seed
```

The seed runs on every deploy, after the migrations. Make it insert only the rows that are missing.

The postgres image also runs the scripts in `/docker-entrypoint-initdb.d`, but only when its data directory is empty. In a stagelet, that is the first deploy, in **Start services**, before the migration command. A bind mount of such a script is refused. Build the script into an image instead. Put a `Dockerfile` and the script in `db/`, and replace the `image` of the `db` service with `build: ./db`:

```dockerfile
FROM postgres:17
COPY init.sql /docker-entrypoint-initdb.d/
```

Use these scripts only for setup that does not need your tables, such as a second database.

## Passwords and connection URLs

The example above puts the password in the Compose file. Stagely stores the resolved Compose model in the deploy bundle that it keeps for resets, so Stagely sees that password. A plain config value is also visible to Stagely. To keep the password in your cloud, use secrets. The values in the Compose file then serve only for local development.

1. In the console, under **Secrets**, add the secret `POSTGRES_PASSWORD` with the agent secret key `db_password`.
2. Add the secret `DATABASE_URL` with the agent secret key `database_url`. Click **Save config**.
3. Store both values on the agent host, as [Config values and secrets](https://stagely.dev/docs/setup/config.md) shows. The URL holds the same password.

Every service gets every secret as an environment variable, and the secret overrides the value in the Compose file. The `db` service reads `POSTGRES_PASSWORD`, and `web` reads `DATABASE_URL`. A Compose file can't use a secret as a variable, so `${POSTGRES_PASSWORD}` inside `DATABASE_URL` does not work. That is why the URL is a secret of its own.

The postgres image reads `POSTGRES_PASSWORD` only when it starts with an empty data directory. After you change the secret, the database of an existing stagelet keeps the old password. To start again with an empty volume, click **Delete** in the console. After the delete finishes, deploy again.

## What Stagely does not do

- It creates no managed database in your cloud account. It creates machines, and your Compose project runs the database on the stagelet's machine.
- It copies no data from production or from another stagelet. Each new stagelet starts with an empty volume.
- It makes no backup of the volume, and a reset does not restore data.

## Start

1. Set up Stagely once with the [Quickstart](https://stagely.dev/docs/setup.md).
2. Add the database service and its named volume, and check them against the [Compose rules](https://stagely.dev/docs/app/compose.md).
3. Add `migration.command` to [.stagely.yml](https://stagely.dev/docs/app/stagely-yml.md), and open a pull request.
