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.
Payload plugin to switch environments and copy databases between dev and prod.
Installation
npm i @whatworks/payload-switch-env15
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
Trends appear after 7 days of data. Daily snapshots have not started yet.
Shared by 12 plugins in what-works-global/payload-packages.
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
Composite score based on GitHub activity, stars, npm downloads, publish recency, dependency count, and package size.
Badge for your README
[](https://payloaddirectory.dev/plugins/1064440892-switch-env)<a href="https://payloaddirectory.dev/plugins/1064440892-switch-env"><img src="https://payloaddirectory.dev/badge/1064440892-switch-env.svg" alt="Payload Directory"></a>README
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:
pnpm i @whatworks/payload-switch-env
3.90.0+. For older Payload versions use @whatworks/payload-switch-env@1.@payloadcms/db-postgres) or SQLite (@payloadcms/db-sqlite). See SQL adapters for the extra schema rules that apply.@payloadcms/storage-s3). Production setups relying solely on local file storage are not supported.developmentFileStorage).⚠️ The
switchEnvPluginmust be last in thepluginsarray, and your cloud storage plugin must be second last.
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).
// 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 }],
},
},
],
})
| Option | Type | Default | Description |
|---|---|---|---|
db | object | required | Database 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). |
enable | boolean | true | Enable or disable the plugin. |
quickSwitch | false | { overwriteDevelopmentDatabase: boolean } | false | Skip the confirmation modal and switch immediately ('switch' mode only). |
developmentFileStorage | object | { 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. |
developmentSafetyMode | boolean | true | When NODE_ENV=development, throws if developmentArgs.url is not localhost/127.0.0.1. |
logDatabaseSize | boolean | false | Logs the serialized backup size when copying the DB (adds a serialization cost). |
copy | object | — | Control which documents and versions are copied to development. See below. |
copyLimit 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 },
},
}
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':
| Setup | Development uploads |
|---|---|
copy mode, collections shared with the storage plugin | staging/public/image.png |
switch mode, or a separate collections object | public/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.
cloud-storage modeIn 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:
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.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).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:
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.Because of this, the workflow for a feature that needs both code and data is migrate first, then populate:
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
copyflow (replicating production into development) is unaffected — it only ever writes to your development database.
copy works on SQL adaptersBoth 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.
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.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.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.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:
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.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:
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.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.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
migrationDiragainst thepayload_migrationsrows. A serverless bundle usually doesn't ship the.tsmigration 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 (anumericcolumn with a numeric default — every Payload app has one inlogin_attempts) against a database that already matches the code exactly, and this mode has no in-sync baseline database to subtract that noise against.
3.90.0+ is required (payload and every @payloadcms/* peer).payloadVersion option is removed — delete it from your switchEnvPlugin() call.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.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.
payload plugin janitor
Find orphaned files, missing uploads and unreferenced documents, then delete only what you approve
7031/wk5v4
payload plugin switch env
Switch between production and development databases or copy production data to development from the Payload admin
430/wk5v3
payload plugin backup mongodb
Back up and restore MongoDB databases and media from the Payload admin using Vercel Blob or S3-compatible storage
380/wk5v3
payload upstash
Upstash Redis key-value adapter for Payload's built-in KV store
690/wk7v3
payload revalidate
Field-driven Next.js cache revalidation and tagged cached finders for Payload collections and globals
67151/wk18v3
payload locale snapshot
Preserves other-locale rows of localized blocks and arrays when saving documents on the PostgreSQL adapter
650/wk3v3