← Back to blog

Preview Environments With Real Data

Iman RadjaviIman Radjavi·

A preview deploy of the frontend is table stakes. The hard part is everything behind it. A preview that points at the shared staging database isn't isolated: one pull request's migration breaks another's, test writes leak into data everyone else relies on, and the feature can't be exercised on anything resembling production. This post is about what a preview environment has to include to be useful, the options for the database, and how Specific builds one per pull request.

What a preview environment is

A preview environment is a temporary, complete copy of an existing environment, created for one change. It runs every service the change touches, on its own URLs, on top of its own copy of the parent environment's state. When the pull request closes, it goes away.

The word doing the work is state. Services are easy to copy; a container image is a container image. The database, the object storage bucket, and the disk a service writes to are what make a preview either useful or misleading.

The database question

There are three common answers to "which database does the preview use", and they differ mostly in how close the preview gets to real data:

  • Seed a fresh database. Migrations run against an empty database, then a seed script fills it. Fast and cheap, but every preview runs on synthetic data, and the seed script is a second schema you have to maintain.
  • Restore from a backup. Closer to reality, but a restore of a large database takes minutes to hours and costs the full storage of a copy.
  • Branch with copy-on-write. The preview's database starts as a pointer to the parent's pages and only stores what it changes. Creation is near-instant regardless of size, and the preview sees real data as of the moment it branched. Neon's branching is the best-known implementation of this for Postgres, and it's what we build on.

Copy-on-write is the only one of the three where a large database is as quick to preview as a small one. The same question then applies to buckets and volumes, which most preview setups quietly skip.

What a preview gets in Specific

When Specific creates a preview from a parent environment, each stateful resource is forked in the way that's fastest for it:

  • Postgres databases become a Neon branch of the parent's database: an instant, copy-on-write copy with its own connection string. If the pull request adds a database that doesn't exist on the parent yet, the preview gets a fresh one instead.
  • Object storage buckets are forked from the parent's Tigris bucket, so uploads and assets are there from the first request.
  • Volumes are cloned copy-on-write, instantly, so a service boots on the parent's uploads, caches, or search index exactly as it would after a restart, and only the blocks the preview changes take up new space.
  • Secrets and config are inherited from the parent. Where a preview should differ, say a sandbox payment key, you set a preview override on the parent's Secrets and config page in the dashboard, and every preview branched from it uses that value.
  • URLs, network namespace, logs, and metrics are the preview's own. specific query --environment <preview> reads its telemetry the same way as any environment.

Writes to a preview never reach the parent. Deleting the preview deletes its branch, buckets, and volumes.

Partial previews

A full copy is the safe default, but for a project with many services it's more than most changes need. Turn on Partial previews in the parent environment's dashboard settings and a preview only deploys the services affected by the change, wired to the parent for everything else.

The scope is computed from the references in specific.hcl, not guessed:

  • A changed service deploys. Any service that talks to it over a private endpoint deploys too.
  • Every database and bucket a deployed service references is cloned. Every other service that references a cloned database or bucket also deploys, so nothing in the preview writes to a parent resource by accident.
  • Crons deploy only if they touch preview-owned state. A cron that would otherwise write into the parent's database is skipped.
  • Unchanged services aren't deployed at all; the preview's services reach them in the parent.
  • If a change affects no service, no preview is created.

How previews get created

Per pull request. With the GitHub integration connected, every open pull request gets a preview. New commits redeploy it, the URL is posted as a PR comment, and it's removed when the PR is merged or closed. Projects can require a specific:preview label before a preview is created; specific status --previews shows how the project is configured.

On demand. specific deploy --preview creates a preview from your checkout that expires after 24 hours. To redeploy an existing preview with a change, pass it as the target: specific deploy --environment <name|id>. Previews are listed with the rest of the environments in specific status.

Here is a project where all of the above applies:

build "app" {
  base    = "node"
  command = "npm run build"
}

postgres "main" {}

storage "uploads" {}

service "api" {
  build   = build.app
  command = "node dist/server.js"

  endpoint {
    public = true
  }

  env = {
    PORT             = port
    DATABASE_URL     = postgres.main.url
    S3_BUCKET        = storage.uploads.bucket
    S3_ENDPOINT      = storage.uploads.endpoint
    S3_ACCESS_KEY    = storage.uploads.access_key
    S3_SECRET_KEY    = storage.uploads.secret_key
    SEARCH_INDEX_DIR = volume.search_index.path
  }

  volume "search_index" {}

  dev {
    command = "npm run dev"
  }
}

cron "digest" {
  build    = build.app
  command  = "node dist/digest.js"
  schedule = "@daily"

  env = {
    DATABASE_URL = postgres.main.url
  }
}

A preview of a change to api branches main, forks uploads, clones search_index, and, with partial previews on, runs digest because it references the cloned database. Nothing about previews is declared in the file: what gets forked follows from what the services reference. The preview settings themselves (partial previews, overrides, the PR label) live in the dashboard.

For a coding agent, this is the loop: it edits specific.hcl and the code, runs specific check, opens the pull request, and reads the preview URL from the comment to verify the change on real data before anyone reviews it.

Common questions

Can a preview modify production data?

No. Every resource the preview writes to is its own branch, fork, or clone. With partial previews, resources that stay in the parent are only reachable by services that were not deployed in the preview, and those services are the parent's own.

What does a preview cost?

A Neon branch stores only the pages that diverge from the parent, and bucket forks and volume clones are copy-on-write too, so an idle preview is cheap. Partial previews reduce compute further by not running unchanged services. On paid plans, the organization's usage breakdown groups preview usage under its parent project.

How long does a preview live?

A pull-request preview lives as long as the pull request is open. A manual preview expires 24 hours after creation, and an expired preview is cleaned up automatically.

Do migrations run in previews?

Yes. A preview is a normal deploy against its own database branch, so pre_deploy hooks and Reshape migrations run against the branch, never the parent.

Try it

The preview environments guide covers the GitHub setup and the dashboard settings, and the Postgres, object storage, and volumes guides describe each resource. The file uploader example (live demo) is a small project with a bucket to preview.

To start in your own project, give your agent this prompt:

Help me get started with Specific by following: https://docs.specific.dev/for-ai/onboarding

Or install the CLI yourself:

curl -fsSL https://specific.dev/install.sh | sh

Then connect GitHub from the dashboard, open a pull request, and read the preview URL from the comment.