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 ($29/month) available on all paid plans (Premium BYOC, Pro, Enterprise, Custom). See add-ons.md and plans.md for pricing details.

How it works

  1. Your organisation stores its staging database URL in the Previewops dashboard as a credential. Previewops syncs that data into a dedicated parent branch for your organisation every night (and on demand).
  2. When /deploy-previewops is triggered, Previewops forks a new preview branch from the parent in seconds — each PR gets its own fully isolated copy.
  3. Two environment variables are injected into the preview container automatically:
    • DATABASE_URL — the preview branch connection string (replaces any DATABASE_URL you have in env:)
    • PREVIEW_DB_URL — same value, useful if you want to reference the preview DB separately from other data stores
  4. When the PR is closed or the TTL expires, the preview branch is deleted automatically.

Setup

Step 1 — Activate the add-on

  1. Log in to the Previewops dashboard.
  2. Navigate to Installations → select your organisation.
  3. Click Add-onsSubscribe next to Preview DB.
  4. Complete the Stripe checkout. The add-on activates immediately.

Step 2 — Store your staging database URL

Back in the dashboard under Credentials, add a single credential entry:

Provider key Credential key Value
preview-db PREVIEW_DB_STAGING_URL postgresql://user:pass@your-staging-host:5432/dbname

This URL is the source Previewops will sync into the preview parent branch nightly. It must be reachable from the Previewops servers (not localhost).

Step 3 — Deploy

No changes to .previewops.yaml are needed. On the next /deploy-previewops command:

Keeping the parent branch in sync

Previewops runs a nightly resync job that dumps your staging database and loads it into the preview parent branch. You can also trigger an on-demand resync from the dashboard:

  1. Navigate to Installations → select your organisation.
  2. Click Preview DBResync now.

Or via the API:

POST /control/db/resync
Authorization: Bearer <session-token>

Branch limits and overage

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

Limitations


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. $29/mo + $1.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
port: 8080

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