Operations › Migrations & deployment
Docs/Operations/Migrations & deployment

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.

npm
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 version
pnpm
pnpm 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 version
yarn
yarn 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 version
bun
bunx 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 version

migrate: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:

ServerReads .env in production?What to do
Next.js (next start)YesNothing, or set the variables on the host
Nuxt (node .output/server/index.mjs)NoSet 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 settingsAdd 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-Id header, and every line logged while answering it has the same requestId. An X-Request-Id from 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 the requestId of 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:

PathAnswersUse for
<api>/healthz200 ok while the process runs; never touches the databaseLiveness: restart the process when it stops answering
<api>/readyz200 when Easy CMS started, its database answers and no migration is pending; 503 otherwiseReadiness: 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: 5

Point 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: GET lists, documents by id and globals, read without a login, a session cookie or a key, and without draft. Everything else stays no-store, so nothing that depends on who asks reaches a shared cache. Browsers get max-age=0 and check back with the ETag.

  • Per collection or global: httpCache: { sMaxAge: 5 } for content that changes often, or httpCache: false to keep it uncached.

  • ETags: every cached read has one; a request with a matching If-None-Match gets 304 without the body.

  • Cache tags: responses carry Cache-Tag (Cloudflare, Bunny) and Surrogate-Key (Fastly, Akamai): ecms, ecms:posts and, for one document, ecms:posts:42; a global has ecms:global:site. purge receives 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 cacheTags from @easy-cms/next, and revalidate them in purge:

    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') in purge.

  • onRequest: a CDN keys its cache by URL (host included). If your onRequest picks the context from something else, such as a header, add that header to Vary at the CDN, or turn httpCache off for those collections.

Checklist

  • EASY_CMS_SECRET set in the production environment (at least 32 random characters: openssl rand -hex 32); see Environment variables
  • NODE_ENV=production
  • The easy-cms/migrations folder deployed, and easy-cms migrate run
  • 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_CODE set 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 trustProxy for per-IP login rate limiting
  • Set auth.trustedOrigins if 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