Operations › Migrations & deployment
Introduction
Content
Using content
Plugins
Operations
Recipes
Migrations & deployment
What you'll learn
How schema changes are applied in development and shipped as migrations to production.
Before this page: Databases.
To try it on Vercel or Netlify in a few clicks, with a database and storage made for you, use the one-click deploy.
Development: automatic
While developing (NODE_ENV is not production), Easy CMS brings the database in line with
your config at startup: new fields become columns, removed fields are dropped.
Renames lose data in development
Renaming a field in development drops the old column and adds a new one. Use a migration (below) when the data matters.
Production: migrations
In production nothing changes automatically. The server refuses to start when migrations are pending or the config changed without one, and tells you what to run.
npx easy-cms migrate:create init # after your first model, and after every change
git add easy-cms/migrations # review the SQL, then commit
npx easy-cms migrate # where you deploy, before starting the new versionpnpm exec easy-cms migrate:create init # after your first model, and after every change
git add easy-cms/migrations # review the SQL, then commit
pnpm exec easy-cms migrate # where you deploy, before starting the new versionyarn easy-cms migrate:create init # after your first model, and after every change
git add easy-cms/migrations # review the SQL, then commit
yarn easy-cms migrate # where you deploy, before starting the new versionbunx easy-cms migrate:create init # after your first model, and after every change
git add easy-cms/migrations # review the SQL, then commit
bunx easy-cms migrate # where you deploy, before starting the new versionmigrate:create compares your config with the last migration. Run in a terminal, it asks whether
a changed field was renamed, so data is kept. Each migration runs in one transaction; a failing
one is rolled back and not recorded.
A database set up by development push can't take migrations; run them against a fresh database.
Environment variables
create-easy-cms puts EASY_CMS_SECRET (and DATABASE_URL, if you use one) in .env. That file
is for development; whether production reads it depends on how the server starts:
| Server | Reads .env in production? | What to do |
|---|---|---|
Next.js (next start) | Yes | Nothing, or set the variables on the host |
Nuxt (node .output/server/index.mjs) | No | Set the variables on the host, or start with node --env-file=.env .output/server/index.mjs |
| Platforms (Vercel, Netlify, Fly, Docker…) | Use the platform's settings | Add the variables in its dashboard, CLI or compose file; see Deploy |
When EASY_CMS_SECRET is missing, the API answers 500 and the log says
secret: is required.
Logs
By default Easy CMS writes readable lines, [easy-cms] …, with an error's stack under it. For a
log pipeline (Datadog, Loki, CloudWatch, docker logs into a collector), write one JSON object
per line instead:
logs: { format: 'json' }or set EASY_CMS_LOG_FORMAT=json on the host, which wins over the config:
{"time":"2026-10-25T14:22:19.280Z","level":"info","name":"easy-cms","message":"GET /api/cms/posts 200","requestId":"lb-7f3a","method":"GET","path":"/api/cms/posts","status":200,"durationMs":7,"userId":3}
{"time":"2026-10-25T14:22:20.011Z","level":"error","name":"easy-cms","message":"REST request failed: …","requestId":"0b6f…","error":{"name":"Error","message":"…","stack":"…"}}- Request ids. Every API response has an
X-Request-Idheader, and every line logged while answering it has the samerequestId. AnX-Request-Idfrom your load balancer is kept when it is plain (letters, digits,.,_,-, up to 128), so one id follows a request from the proxy's log to Easy CMS's. - A line per request, with method, path (without the query string, which may hold personal
data), status, duration and the user's id. Turn it off with
logs: { requests: false }when the load balancer already logs requests. - Background jobs. Each run of scheduled publishing and jobs logs with its own
runId, and with therequestIdof the cron call that started it.
easy-cms serve follows the same setting. With your own logger passed to createEasyCMS,
logs is ignored: jsonLogger() from @easy-cms/core writes this format.
Traces and metrics through OpenTelemetry: Observability.
Health checks
Load balancers, container platforms and Kubernetes ask two questions:
| Path | Answers | Use for |
|---|---|---|
<api>/healthz | 200 ok while the process runs; never touches the database | Liveness: restart the process when it stops answering |
<api>/readyz | 200 when Easy CMS started, its database answers and no migration is pending; 503 otherwise | Readiness: send traffic only to instances that can serve it |
<api> is routes.api, /api/cms by default, so every setup has them: Next.js, Nuxt and the
standalone server, which also answers /healthz and /readyz at the root.
Neither needs credentials, and neither is cached.
{ "status": "ok", "checks": { "database": "ok", "migrations": "ok" } }
{ "status": "error", "checks": { "database": "ok", "migrations": "1 pending" } }A failed check is named, never explained: the error goes to the log, with its
request id. When Easy CMS can't start (a bad config, an unreachable database),
/healthz still answers 200 and /readyz answers 503 with "startup": "error".
# Kubernetes
livenessProbe:
httpGet: { path: /api/cms/healthz, port: 3000 }
readinessProbe:
httpGet: { path: /api/cms/readyz, port: 3000 }
periodSeconds: 5Point liveness at healthz, not readyz: a database outage should take instances out of the
load balancer, not restart them all. Probes are left out of the request log lines.
HTTP caching
By default every API response is no-store: each read reaches the database. With a CDN or a
caching proxy in front, let it keep public reads of published content:
httpCache: {
sMaxAge: 60, // the CDN serves it for a minute without asking again
staleWhileRevalidate: 300, // then for five more while it fetches a fresh copy
purge: async (tags) => {
await fetch('https://api.cloudflare.com/client/v4/zones/<zone>/purge_cache', {
method: 'POST',
headers: { authorization: `Bearer ${process.env.CF_TOKEN}`, 'content-type': 'application/json' },
body: JSON.stringify({ tags }),
})
},
},-
What is cached:
GETlists, documents by id and globals, read without a login, a session cookie or a key, and withoutdraft. Everything else staysno-store, so nothing that depends on who asks reaches a shared cache. Browsers getmax-age=0and check back with the ETag. -
Per collection or global:
httpCache: { sMaxAge: 5 }for content that changes often, orhttpCache: falseto keep it uncached. -
ETags: every cached read has one; a request with a matching
If-None-Matchgets304without the body. -
Cache tags: responses carry
Cache-Tag(Cloudflare, Bunny) andSurrogate-Key(Fastly, Akamai):ecms,ecms:postsand, for one document,ecms:posts:42; a global hasecms:global:site.purgereceives the tags of what readers saw change (a save of a published document, publishing, unpublishing, deleting; not drafts). -
Next.js: tag your own fetches with
cacheTagsfrom@easy-cms/next, and revalidate them inpurge:import { cacheTags } from '@easy-cms/next' import { revalidateTag } from 'next/cache' await fetch(`${api}/posts/${id}`, { next: { tags: cacheTags.document('posts', id) } }) // in the config httpCache: { purge: (tags) => tags.forEach((tag) => revalidateTag(tag)) } -
Nuxt: with a Nitro cached handler keyed by these tags, remove the matching entries from
useStorage('cache')inpurge. -
onRequest: a CDN keys its cache by URL (host included). If youronRequestpicks the context from something else, such as a header, add that header toVaryat the CDN, or turnhttpCacheoff for those collections.
Checklist
EASY_CMS_SECRETset in the production environment (at least 32 random characters:openssl rand -hex 32); see Environment variablesNODE_ENV=production- The
easy-cms/migrationsfolder deployed, andeasy-cms migraterun - Start the server from the project root: relative database paths, migrations and uploads resolve from the working directory
- Uploads on local disk need a persistent volume; on serverless platforms use S3 storage, Vercel Blob or Netlify Blobs
EASY_CMS_SETUP_CODEset until the first admin exists, so nobody else can claim the site- Build on the same OS and architecture as the server when using SQLite (native driver)
- Behind a trusted proxy, enable
trustProxyfor per-IP login rate limiting - Set
auth.trustedOriginsif the admin or frontend call the API from another origin - Backups of the database and uploads, and a restore you have tried
After going live, the dashboard tells admins what needs attention: see Health checks.
Next steps
- Deploy: step by step on Vercel, Netlify, Docker, a VPS and other hosts.
- Reference architecture: several instances behind a load balancer.
- Backups & upgrades: back up before upgrading.
- Security: a checklist for going live.