Databases in Preview Environments

Previewops offers two ways to connect a preview environment to a database: the Preview DB add-on (automatic, zero-config, fully isolated) and manual URL injection (configure once in .previewops.yaml).


Preview DB add-on — automatic per-PR branches

The Preview DB add-on provisions a fresh Postgres branch for every pull request automatically, with no changes to .previewops.yaml required.

Availability: Preview DB is a paid add-on ($39/month) available on all paid plans (Premium BYOC, Pro, Enterprise, Custom). See add-ons.md and plans.md for pricing details.

How it works

Previewops manages a managed Postgres project on your behalf. Every organisation with the add-on gets a dedicated parent branch that acts as the snapshot base for all PR branches.

When /deploy-previewops is triggered, Previewops provisions a database branch for the PR — reusing the existing branch if the PR already has one active, or forking a new one from the parent otherwise. Two environment variables are injected into the preview container automatically:

Override behaviour: Preview DB injects the branch connection string into DATABASE_URL (and PREVIEW_DB_URL) by default, overriding any value you have set in your .previewops.yaml env: block. If you need to preserve an existing DATABASE_URL, configure previewDb.envVar in .previewops.yaml to redirect the injection to a different key:

previewDb:
  envVar: BRANCH_DB_URL   # Preview DB injects here; DATABASE_URL from env: is untouched

A warning notice is shown in the PR dashboard comment whenever Preview DB overwrites a key you have explicitly set.

When the PR is closed or the TTL expires, the preview branch is deleted automatically. If a PR has multiple environments (e.g. a monorepo deploying staging and production in the same PR), the branch is kept alive until all environments for that PR are removed — deleting one environment while another is still active preserves the shared database branch for the remaining environment.

Silent-failure behaviour: If branch provisioning fails (e.g. a transient API error), the deploy continues without a database. DATABASE_URL and PREVIEW_DB_URL are not injected, and the PR comment will show a ⚠️ warning. Re-running /deploy-previewops will retry provisioning if no branch exists for this PR yet.

Choose your data flow

There are three ways to use the Preview DB add-on depending on how much production-like data you want in each PR branch:

Flow 1 — Empty branch, migrations only (simplest)

The first deploy for a PR forks a fresh empty branch. Subsequent deploys on the same PR reuse that branch — data written by a previous preview is still there. Your application applies its own migrations at container startup.

When to use: The app creates its schema from scratch (e.g. using Prisma Migrate, Flyway, Alembic). No staging data is required.

What to do:

Flow 2 — Staging data snapshot (most realistic, no migrations)

Each PR branch is seeded with a nightly copy of your staging database. Useful when the PR does not include schema changes.

When to use: PRs test UI/API changes against realistic data, and the schema is stable.

What to do:

  1. Subscribe to the add-on.
  2. Enter your staging database connection string in the Preview DB panel on your installation page — use the Staging database URL for seeding field. Previewops reads this server-side to seed the parent branch — it is never injected into your containers.
  3. In the Preview DB panel, click Resync to seed the parent branch immediately (or wait for the nightly job).

Flow 3 — Staging data + PR migrations (production-realistic)

Each PR branch is seeded with staging data, then the PR's own migrations are applied at container startup. This is the most realistic option for teams that ship schema changes with every PR.

When to use: PRs include database migrations that build on top of existing staging data.

What to do:

  1. Subscribe to the add-on and configure PREVIEW_DB_STAGING_URL as in Flow 2.
  2. Add both migration and startup steps to your container entrypoint:
    # Example entrypoint
    npm run db:migrate && npm start
    
    On the first deploy, the container receives DATABASE_URL pointing at a freshly-forked branch seeded with staging data, so npm run db:migrate applies the PR's migrations on top of the snapshot. On subsequent deploys (redeploying the same PR), the same branch is reused — make sure your migration tool handles already-applied migrations gracefully (most do: Prisma Migrate, Flyway, Liquibase, and golang-migrate all track applied versions and skip them).

Setup

Step 1 — Activate the add-on

  1. Log in to the Previewops dashboard.
  2. Click Add-ons in the left sidebar → Subscribe next to Preview DB.
  3. Complete the Stripe checkout. The add-on activates immediately.

Step 2 — (Flow 2 & 3) Store your staging database URL

On your installation page, scroll to the Preview DB panel. Use the Staging database URL for seeding field to enter your staging Postgres connection string, then click Save.

The URL must use the postgres:// or postgresql:// scheme and point to a remotely reachable host (not localhost).

Step 3 — Bootstrap the parent branch

The parent branch is created automatically the first time you run /deploy-previewops on any PR (or when you click Resync in the Preview DB panel on your installation page). You do not need to create or manage any resources manually.

Bootstrap timing note: The parent branch is created automatically on the first /deploy-previewops run. No manual setup is required — simply run a deploy on any PR to trigger provisioning.

Step 4 — Deploy

No changes to .previewops.yaml are needed. On the next /deploy-previewops command, a preview branch is forked and DATABASE_URL / PREVIEW_DB_URL are injected into the container.

Keeping the parent branch in sync

For Flow 2 and Flow 3, Previewops runs a nightly resync that dumps your staging database and loads it into the preview parent branch. All PR branches forked the following day will include the latest data.

To trigger an on-demand resync from the dashboard:

  1. Click Installations in the left sidebar.
  2. In the Preview DB panel, click Resync.

The dashboard shows the last successful resync timestamp and a warning badge if no staging URL is configured.

You can also trigger a resync via the API:

POST /control/db/resync

Requires an active Previewops dashboard session (GitHub OAuth). This endpoint cannot be called with a CLI token.

The endpoint returns:

Branch limits and overage

Each plan includes 30 Preview DB branches per month. Branches above that cap are billed at $2.00 per branch via Stripe metered billing. The counter resets on the 1st of each month.

The PR comment shows a note when a branch was provisioned as an overage branch.

Limitations

Resetting a branch: Because Previewops reuses the same database branch across redeploys on a PR, test data accumulates over time. To provision a fresh database branch (e.g. after test data has been corrupted), run /delete-previewops first to remove the existing preview and its branch, then redeploy.

Named branches (--db-branch)

By default, Previewops provisions one unnamed database branch per PR and reuses it across redeploys. The --db-branch flag lets you deploy with a named, independent branch — useful when one PR needs to test against multiple different data sets simultaneously.

/deploy-previewops --db-branch=seed-v2
/deploy-previewops --provider=fly --db-branch=empty
/deploy-previewops --provider=cloud-run --db-branch=seeded

When to use:

Behaviour:

Alias format: lowercase alphanumeric and hyphens only, 1–50 characters, must start with a letter or digit (my-seed, v2, empty-db).

Skipping Preview DB for a single deploy: Use --database=env to bypass Preview DB provisioning entirely for one deploy. Previewops will use whatever DATABASE_URL you have in your .previewops.yaml env: block instead. This has no effect if the add-on is not active.

Deleting named branches: Each named branch is treated as a separate resource. Deleting a specific environment (e.g. /delete-previewops --env=staging) removes that cloud service and its associated named branch if no other active preview for the same PR references the same branch ID. Running /delete-previewops without options removes all environments and all their branches.


Manual database configuration

If you are not using the Preview DB add-on, you can inject a database URL (or any environment variable) directly in .previewops.yaml.

How it works

Add an env: map to your .previewops.yaml. Every key/value pair in that map is injected as an environment variable into the running preview container:

env:
  DATABASE_URL: postgresql://postgres:postgres@db.staging.example.com:5432/myapp_preview

Previewops sets these variables in the container spec when it deploys the service. That is the full extent of its involvement — it does not connect to your database, does not run migrations, and has no visibility into whether your container can actually reach the database host.


The localhost problem

If your local development uses Docker Compose, your DATABASE_URL probably contains localhost:

DATABASE_URL="postgresql://postgres:postgres@localhost:5432/myapp_dev"

This will not work in a preview container. Inside a container, localhost refers to the container itself — not the host machine, and not a separate database container running alongside it. There is no Postgres server listening on localhost:5432 inside your preview container.

To fix this, replace localhost with an externally reachable hostname or IP address:

# ❌ Won't work — localhost is the container itself
env:
  DATABASE_URL: postgresql://postgres:postgres@localhost:5432/myapp_dev

# ✅ Works — fully-qualified external host
env:
  DATABASE_URL: postgresql://postgres:postgres@db.staging.example.com:5432/myapp_preview

Previewops' limitations (manual configuration)

Previewops deploys your container and injects the env vars you configure. Everything beyond that is your responsibility:

Area Previewops' role Your responsibility
Injecting DATABASE_URL into the container ✅ Done automatically via env: Configure the value in .previewops.yaml
Database provisioning ❌ Not supported Create and manage the DB yourself (or use the Preview DB add-on)
Database teardown ❌ Not supported Clean up the DB yourself when the preview is deleted (Preview DB add-on handles this automatically)
Running migrations ❌ Not supported Add a migration step to your Dockerfile or entrypoint
Network reachability ❌ No control Ensure your DB host is reachable from your cloud provider
Secret management ❌ No encryption Values in env: are stored in plaintext in .previewops.yaml

Network reachability

Preview containers run inside your own cloud account (that is what BYOC means). Whether a container can reach a database depends entirely on your cloud infrastructure:

Previewops has no visibility into or control over these networking decisions.


Handling credentials safely

The env: block in .previewops.yaml is committed to your repository. Any values you put there are visible to anyone with read access to the repo.

For database URLs that contain passwords, choose one of these approaches:

Option 1 — Use a dedicated low-privilege preview database

Create a separate database instance (or a separate user) for previews with credentials that only have access to the preview database. Because the credentials are not shared with production, committing them in .previewops.yaml carries limited exposure:

env:
  DATABASE_URL: postgresql://preview_user:throwaway_pass@db.staging.example.com:5432/myapp_preview

This is the simplest approach and works well for most teams.

Option 2 — Use your cloud provider's secret injection

Instead of hard-coding the URL, inject it at container startup using your cloud provider's secret management:

With this approach, DATABASE_URL is not in .previewops.yaml at all — the cloud provider injects it before the container starts.


Running migrations

Previewops does not run database migrations. Common patterns for handling migrations in preview environments:

Run migrations on container startup

Add a migration command to your Dockerfile entrypoint so it runs automatically before your app starts:

# Example entrypoint.sh (adjust commands for your language/framework)
#!/bin/sh
set -e
npm run db:migrate    # run pending migrations
exec node dist/index.js   # then start the app
CMD ["sh", "entrypoint.sh"]

This is the simplest approach — every preview applies its own migrations on startup against the configured DATABASE_URL. Adjust the migration command to match your framework (e.g. python manage.py migrate, ./bin/rails db:migrate, flyway migrate, etc.).

Point at an already-migrated database

If you use a shared staging database that is kept up to date by your CI pipeline, your previews can skip migrations entirely and just connect. No entrypoint changes needed.

Skip the database entirely

For previews that do not need real data, consider stubbing or mocking the data layer in a NODE_ENV=preview code path. This avoids the need for a database connection altogether.


Using SQLite

SQLite writes the database to a local file inside the container. This can work in preview environments, but only on providers with a persistent filesystem. On providers that reset container storage between restarts (scale-to-zero, machine recycling), any data written to the SQLite file will be silently lost.

Provider Filesystem SQLite viable?
Cloud Run Ephemeral — reset on scale-to-zero ❌ No — data lost between requests
Fly Ephemeral — reset on machine restart ❌ No — data lost on restart
AWS ECS Ephemeral ❌ No
Azure Container Apps Ephemeral ❌ No
AWS Lightsail Instance disk — persists across restarts ✅ Yes
Docker SSH Server disk — persists if a volume is mounted ✅ Yes (with volume)

SQLite on Lightsail

Lightsail instances have a persistent disk, so SQLite works without any extra configuration:

provider: aws-lightsail
env:
  DATABASE_URL: file:/data/preview.db

SQLite on Docker SSH

For Docker SSH, the container filesystem is ephemeral unless you mount a volume from the host in your docker-compose.yml. Without a volume mount, the SQLite file is lost when the container restarts:

# docker-compose.yml — add a named volume for the SQLite file
services:
  app:
    image: ${IMAGE}
    volumes:
      - preview-data:/data

volumes:
  preview-data:

Then point your app at the mounted path:

# .previewops.yaml
provider: docker-ssh
env:
  DATABASE_URL: file:/data/preview.db

SQLite on Cloud Run or Fly

Do not use SQLite as a persistent store on Cloud Run or Fly. These providers recycle containers regularly and write nothing to durable storage. If your app writes to a SQLite file, that data will be lost the next time the container restarts — with no error.

For Cloud Run and Fly, use an external database. See the Common patterns section below.


Common patterns

Pattern How it works Trade-offs
Preview DB add-on Previewops forks a database branch per PR from your staging DB; DATABASE_URL injected automatically Fully automated — zero .previewops.yaml changes. Postgres only. $39/mo + $2.00/branch above 30.
Shared staging DB All previews share one external DB; DATABASE_URL is a single host/database Simple — no provisioning needed. Previews share data, so tests can interfere with each other.
Isolated preview DB per PR Each PR gets its own DB or schema; user provisions and tears it down Clean isolation. Requires automation outside Previewops (e.g. a GitHub Actions step) to create the DB before deploying and delete it after the PR closes.
SQLite (Lightsail or Docker SSH only) App writes to a local file in the container Zero external setup. Only viable on providers with persistent storage — will silently lose data on Cloud Run or Fly.
No database App uses mock/stub data when NODE_ENV=preview Zero setup. Only viable if your app can run without a real DB.

Example .previewops.yaml

provider: cloud-run
ttlHours: 48
memory: 512Mi
cpu: 1

env:
  NODE_ENV: preview
  DATABASE_URL: postgresql://preview_user:throwaway_pass@db.staging.example.com:5432/myapp_preview
  API_BASE_URL: https://api.staging.example.com