what-works-global

switch env

by what-works-global
payload-packages
Databases & data
v3
>=3.90.0 <4

Overview

Summary generated from the README

Adds an admin button that points a local Payload admin panel at either the development or production database, or copies production into development. It supports MongoDB and the Postgres and SQLite Drizzle adapters, and is registered as the last plugin after the cloud storage plugin. Production uploads must use a cloud storage adapter, and Payload 3.90 or newer is required.

Features

  • Switch the admin panel between development and production databases
  • Copy production into development with limits on documents and versions
  • Development uploads can use the local file system or a prefixed cloud storage path
  • On SQL adapters, blocks switching to production when the schema has drifted
  • Postgres and SQLite copy replays production schema, loads rows and applies local migrations
  • Safety mode requires a localhost development database URL

Use cases

  • Fix or enter data in production from a local admin without overwriting live updates
  • Refresh a development or staging database with a copy of production data
  • Test local schema changes against realistic production data

From the author

Payload plugin to switch environments and copy databases between dev and prod.

Installation

npm i @whatworks/payload-switch-env

15

Stars

1

Forks

1

Open Issues

2 weeks ago

Last Updated

0

Weekly Downloads

v2.0.1

npm Version

451.9 KB

Unpacked Size

0

Dependencies

Repository stars

Trends appear after 7 days of data. Daily snapshots have not started yet.

Shared by 12 plugins in what-works-global/payload-packages.

npm weekly downloads

Trends appear after 7 days of data. Daily snapshots have not started yet.

Each point is npm's rolling 7-day download count.

Health Score

59/100Good

Composite score based on GitHub activity, stars, npm downloads, publish recency, dependency count, and package size.

Badge for your README

Payload Directory health badge
Markdown
[![Payload Directory](https://payloaddirectory.dev/badge/1064440892-switch-env.svg)](https://payloaddirectory.dev/plugins/1064440892-switch-env)
HTML
<a href="https://payloaddirectory.dev/plugins/1064440892-switch-env"><img src="https://payloaddirectory.dev/badge/1064440892-switch-env.svg" alt="Payload Directory"></a>
Updated Oct 6, 2026Created Sep 26, 2025Published Sep 30, 2026

README

@whatworks/payload-switch-env

@whatworks/payload-switch-env

 

A Payload plugin that lets you switch your local admin panel between your development and production databases — and copy production into development — at the press of a button.

Two common uses:

  • Edit production data locally. Point the admin panel at the production database to enter or fix data — without losing business-as-usual updates (orders, form submissions, client edits) that a clone-and-overwrite workflow would clobber. On SQL adapters this is data-only: the schema must already match (see SQL adapters).
  • Replicate production into development. Copy the production database to your local database in one click. Upload documents keep referencing the files in your production cloud storage, so nothing needs to be synced to disk. Documents not created during development are protected from edits/deletes.

Demo

demo.webm

Install

pnpm i @whatworks/payload-switch-env

Requirements & limitations

  • Payload 3.90.0+. For older Payload versions use @whatworks/payload-switch-env@1.
  • Databases: MongoDB, or a Drizzle SQL adapter — Postgres (@payloadcms/db-postgres) or SQLite (@payloadcms/db-sqlite). See SQL adapters for the extra schema rules that apply.
  • Production uploads must use a cloud storage adapter (e.g. @payloadcms/storage-s3). Production setups relying solely on local file storage are not supported.
  • In development, uploads can use either the local file system or cloud storage (see developmentFileStorage).

Plugin ordering

⚠️ The switchEnvPlugin must be last in the plugins array, and your cloud storage plugin must be second last.

Why?

The cloud storage plugin adds url fields plus afterChange/afterDelete hooks to upload collections, and (with clientUploads) a signed-URL endpoint. In file-system development mode switchEnv removes those upload/delete hooks and wraps the afterRead hooks on the url fields, assuming the cloud storage hooks are last in each array; in cloud-storage mode it wraps the signed-URL endpoint, which must already exist. Keeping cloud storage second-last ensures no other plugin breaks those assumptions.

Being last doesn't stop switchEnv from giving copy-mode storage options their development prefix before the storage plugin reads them: that happens when switchEnvPlugin({...}) is called, while the plugins array is built, and buildConfig only applies plugins afterwards (see Development storage prefix).

Usage

// payload.config.ts
import { type Args, mongooseAdapter } from '@payloadcms/db-mongodb'
import { s3Storage } from '@payloadcms/storage-s3'
import { buildConfig } from 'payload'
import { switchEnvPlugin, adminThumbnail } from '@whatworks/payload-switch-env'

const dbArgs: Args = {
  url: process.env.DATABASE_URI!,
}

export default buildConfig({
  db: mongooseAdapter(dbArgs),
  plugins: [
    // Cloud storage plugin: second last
    s3Storage({
      bucket: process.env.S3_BUCKET!,
      collections: { media: true },
      config: {
        credentials: {
          accessKeyId: process.env.S3_ACCESS_KEY_ID!,
          secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
        },
        region: process.env.S3_REGION,
      },
    }),
    // switchEnvPlugin: last
    switchEnvPlugin({
      enable: process.env.NODE_ENV === 'development',
      db: {
        function: mongooseAdapter,
        productionArgs: dbArgs,
        developmentArgs: {
          ...dbArgs,
          url: process.env.DEVELOPMENT_DATABASE_URI || '',
        },
      },
      copy: {
        versions: {
          default: { mode: 'latest-x', x: 3 },
        },
      },
    }),
  ],
  collections: [
    {
      slug: 'media',
      fields: [{ name: 'alt', type: 'text' }],
      upload: {
        // Optional: link admin thumbnails directly to cloud storage
        // (`${basePath}/${prefix}/${_objectKey}/${filename}`)
        adminThumbnail: adminThumbnail({
          basePath: `https://${process.env.S3_BUCKET}.s3.${process.env.S3_REGION}.amazonaws.com`,
          imageSize: 'thumbnail',
        }),
        imageSizes: [{ name: 'thumbnail', width: 300, height: 300 }],
      },
    },
  ],
})

Options

OptionTypeDefaultDescription
dbobjectrequiredDatabase adapter function plus productionArgs and developmentArgs.
buttonMode'switch' | 'copy''switch''switch' toggles between production and development; 'copy' shows a button that copies the production DB into development (useful for staging).
enablebooleantrueEnable or disable the plugin.
quickSwitchfalse | { overwriteDevelopmentDatabase: boolean }falseSkip the confirmation modal and switch immediately ('switch' mode only).
developmentFileStorageobject{ mode: 'file-system' }Where dev uploads go: { mode: 'file-system' } or { mode: 'cloud-storage', prefix, collections }. In cloud-storage mode, pass as collections the same object you pass to your storage plugin. See Development storage prefix.
developmentSafetyModebooleantrueWhen NODE_ENV=development, throws if developmentArgs.url is not localhost/127.0.0.1.
logDatabaseSizebooleanfalseLogs the serialized backup size when copying the DB (adds a serialization cost).
copyobject—Control which documents and versions are copied to development. See below.

copy

Limit how much data is copied when replicating production to development. Both documents and versions accept a default mode plus per-collections/globals overrides:

  • { mode: 'all' } — copy everything.
  • { mode: 'latest-x', x: number } — copy only the latest x (documents, or versions per document).
  • { mode: 'none' } — copy nothing (for versions, keeps only each document's latest version).
copy: {
  documents: {
    default: { mode: 'all' },
    collections: { logs: { mode: 'latest-x', x: 50 } },
  },
  versions: {
    default: { mode: 'latest-x', x: 3 },
  },
}

Development storage prefix

In cloud-storage mode, uploads made in development are stored under prefix so they never share storage keys with production files. With a collection prefix of public and prefix: 'staging':

SetupDevelopment uploads
copy mode, collections shared with the storage pluginstaging/public/image.png
switch mode, or a separate collections objectpublic/staging/image.png

Admin (client) uploads add their per-upload _objectKey folder, e.g. staging/public/<_objectKey>/image.png.

For the staging/public layout, give the storage plugin and developmentFileStorage.collections the same object — typically on a staging deployment in copy mode:

const storageCollections = {
  media: { prefix: 'public' },
}

export default buildConfig({
  // ...
  plugins: [
    s3Storage({
      bucket: process.env.S3_BUCKET!,
      clientUploads: true,
      collections: storageCollections,
      // ...
    }),
    switchEnvPlugin({
      buttonMode: 'copy',
      developmentFileStorage: {
        mode: 'cloud-storage',
        prefix: 'staging',
        collections: storageCollections, // the same object as above
      },
      // ...
    }),
  ],
})

Payload contains every new upload beneath the collection prefix its storage plugin read when the config was built. In copy mode the environment is always development, so the plugin gives the shared collection options the development prefix as soon as switchEnvPlugin({...}) is called — before buildConfig applies the storage plugin — and the storage plugin then works under staging/public itself. switch mode changes environment at runtime, so there the development area is nested inside the collection prefix instead. Documents always resolve from the prefix they were stored with.

If the storage plugin did not pick up the development prefix — for example because it was given a separate collections object — the plugin falls back to the nested layout for that collection and logs a warning on init.

Duplicate filenames in cloud-storage mode

In cloud-storage mode a single database holds documents under different storage prefixes: development uploads under the development prefix (e.g. staging/private/file.zip) and documents copied from production under their original prefix (e.g. private/file.zip). Payload's duplicate-filename check is scoped to the incoming document's prefix, but by default the unique index on filename spans the whole collection — so uploading a filename that exists under another prefix would fail with The following field is invalid: filename even though the storage keys don't collide.

The plugin therefore sets

upload: {
  filenameCompoundIndex: ['filename', 'prefix'],
}

on every upload collection it manages (those listed in developmentFileStorage.collections with a prefix), unless you set filenameCompoundIndex yourself. Uniqueness is then scoped to (filename, prefix): the same filename can exist under different prefixes (they are different storage keys), while uploading a duplicate filename within a prefix still deduplicates normally (file-1.zip, file-2.zip, ...).

Because this changes the collection's indexes, two things to note for existing setups:

  • If the plugin is disabled in some environments (e.g. enable: process.env.NODE_ENV === 'development'), set filenameCompoundIndex explicitly in the collection config instead, so the schema is identical with and without the plugin — otherwise uploads in those environments still hit the collection-wide unique index.
  • Existing databases keep their old unique index on filename until migrated. On MongoDB the plugin handles this for you: in the development cloud-storage environment it creates the new compound index and drops the superseded single-field filename unique index automatically (on init and after a runtime switch). On SQL adapters it is a schema migration through your normal pipeline (the plugin refuses to switch to production until production's schema matches).

SQL adapters (Postgres / SQLite)

MongoDB is schemaless, so switching to production with locally-changed fields just works. SQL adapters are schema-bound, so the plugin enforces that the only way to change a production schema is a proper migration — never a switch. Two safeguards make this safe:

  1. Production is never schema-pushed. The production adapter is built with push: false, so connecting to production never runs Drizzle's dev schema push — not on switch, and not when the dev server hot-reloads while you're connected to production. So if you edit a collection in prod mode, your change is not applied to the production database.
  2. Switching to production is blocked on schema drift. Before switching, the plugin runs a Drizzle dry-run diff against production. If the production schema doesn't match your local schema, the switch is refused and the pending changes are listed. Nothing is applied.

Because of this, the workflow for a feature that needs both code and data is migrate first, then populate:

  1. Ship the schema migration to production through your normal migration pipeline.
  2. Once production matches your local schema, switch to production and enter/fix the data.

If you edit a field while connected to production, queries touching the new column will error (e.g. no such column) until production is migrated and the schemas line up again. This is intentional — it fails loudly instead of silently mutating production.

These rules are SQL-only; nothing here changes MongoDB behavior. The copy flow (replicating production into development) is unaffected — it only ever writes to your development database.

How copy works on SQL adapters

Both Drizzle adapters are supported. The flow reads from production and rewrites your development database in place — it never mutates production. It also works when your development schema has progressed past production (added/removed fields, new or deleted collections): the copy recreates production's schema first, loads the data into it, then brings the schema forward to match your local code.

  1. Capture. Production's schema DDL is captured alongside its rows — from sqlite_master on SQLite, from the system catalogs on Postgres (extensions, enums, sequences, tables, constraints, indexes — a pg_dump equivalent with no external binary required). Only extensions installed inside the copied schema are carried over; provider-managed extensions living in their own schemas (Supabase's extensions/vault, Neon's neon) are not part of the replica and are skipped with a warning if they can't be recreated locally.
  2. Replay. The development database is wiped (every table on SQLite; DROP SCHEMA ... CASCADE on Postgres, inside a transaction so a failed copy rolls back untouched) and production's DDL is replayed — production's rows always fit exactly, regardless of local schema drift.
  3. Load. Rows are bulk-loaded. On Postgres, foreign-key enforcement is suspended with SET LOCAL session_replication_role = 'replica' (requires a sufficiently privileged role on the development connection — the local user you normally develop against is fine), and identity sequences are advanced past the restored rows so later inserts don't collide.
  4. Migrate forward. Local migration files that the source hasn't run yet are applied, so their renames and backfills transform the production data the way the migration author intended.
  5. Reconcile. Remaining unmigrated dev-only schema changes are applied with a non-interactive Drizzle push — on a push-managed target only, see below. Data-loss warnings are logged instead of prompted — it's your development database, which the copy just deliberately rewrote. Unambiguous drift (added fields, removed fields, new or deleted collections, and any rename covered by a migration file) always reconciles automatically. Views owned by an extension installed in the schema itself (e.g. pg_stat_statements on setups where CREATE EXTENSION defaulted to public) can never be dropped by a push; the reconcile skips them with a warning instead of failing.

An unmigrated rename is the one thing the reconcile refuses to guess: it is indistinguishable from a remove+add, and drizzle-kit's push would normally stop and ask "created or renamed?" on stdin — impossible inside an endpoint, and answering wrong silently empties the renamed field. When the reconcile detects that shape (a table, column, or enum type that was both created and deleted), it pauses: the reconcile is skipped and the response lists the ambiguous pairs. Your development database is left as a pure production replica (plus applied migrations), so nothing is lost. Then either:

  • In development — restart the dev server: Payload's own boot-time schema push resolves the renames interactively in your terminal. Or add a migration for the rename and copy again.
  • On a staging deployment (buttonMode: 'copy', where Payload never runs its dev push) — a pause means this environment is missing the migrations for those schema changes. Deploy them; payload migrate runs against the freshly copied database and resolves the difference in place, no re-copy needed.

Migration-managed targets (hosted staging)

Step 5 only applies to a database whose schema is owned by Drizzle push — your local development database. A migration-managed target (one whose schema only ever changes through payload migrate, typically a hosted staging environment) is never pushed to, and the copy behaves differently. The plugin decides which it is with exactly the inverse of the gate Payload's own adapters use before pushing:

// @payloadcms/db-postgres, db-sqlite — connect()
if (
  process.env.NODE_ENV !== 'production' &&
  process.env.PAYLOAD_MIGRATING !== 'true' &&
  this.push !== false
) {
  await pushDevSchema(this)
}

So a target is treated as migration-managed when push: false is set on it, or when it runs with NODE_ENV=production (which Vercel and most hosts set for every environment, staging included). No guessing from buttonMode or environment names is involved — it is the adapter's own schema policy.

On such a target the copy:

  • never writes the batch = -1 "dev" row. That row is Payload's record of a schema change made outside migration history, and payload migrate refuses to run non-interactively while one exists — it prompts "It looks like you've run Payload in dev mode … data loss will occur. Would you like to proceed?" on stdin, which stops a CI/Vercel build dead. A copy that only restored a replica and ran migrations has no such change to record.
  • never pushes schema. If migrations leave the schema short of what the code expects, that is reported, not papered over — a push can reproduce DDL but never a migration's data backfill.
  • refuses a source that itself carries batch = -1, before anything on the target is touched. Such a source's live schema isn't fully described by its migration history, so the marker can neither be carried over (it would block the target's next migrate) nor dropped (that would claim a schema the migrations don't produce). Fix the source first.
  • reports an incomplete copy (status: 'incomplete' in the endpoint response, plus a warning toast and a server-log warning) when a migration fails or when migration files on disk have no payload_migrations row afterwards. A failing migration is caught even though Payload's runner answers one with process.exit(1).

For a hosted staging app, declaring the policy explicitly is worth it even though NODE_ENV=production already implies it:

switchEnvPlugin({
  db: {
    function: postgresAdapter,
    developmentArgs: {
      pool: { connectionString: process.env.DEVELOPMENT_DATABASE_URI },
      // Staging's schema comes from `payload migrate` in the build, never from a push.
      ...(process.env.APP_ENV === 'staging' ? { push: false } : {}),
    },
    productionArgs: { pool: { connectionString: process.env.DATABASE_URI } },
  },
})

Migration completeness is checked by comparing the files in migrationDir against the payload_migrations rows. A serverless bundle usually doesn't ship the .ts migration sources, in which case the check reports "unchecked" rather than "clean". The read-only Drizzle diff that runs alongside it is logged as advisory only: drizzle-kit re-emits no-op statements (a numeric column with a numeric default — every Payload app has one in login_attempts) against a database that already matches the code exactly, and this mode has no in-sync baseline database to subtract that noise against.

Upgrading from 1.x

  • Payload 3.90.0+ is required (payload and every @payloadcms/* peer).
  • The payloadVersion option is removed — delete it from your switchEnvPlugin() call.
  • In cloud-storage mode, pass the same collections object to your storage plugin and to developmentFileStorage.collections. In copy mode that keeps development uploads under staging/public/...; in switch mode, or with a separate object, new development uploads are nested inside the collection prefix (public/staging/...). Documents uploaded earlier keep resolving from their stored prefix. See Development storage prefix.
  • adminThumbnail() now includes the _objectKey folder Payload 3.90 gives client uploads, so admin thumbnails for those uploads resolve again.

Caution

In 'switch' mode the admin panel writes directly to your production database. Make sure everyone on the project understands when they are in "production" mode.

Similar plugins