Skip to content

New project → production

This guide is for the developer with a fresh project on their laptop. It walks the work you do before Mezzanine ever sees your code, so that when you do deploy, it builds the first time, keeps its data, and comes up healthy.

Every rule here exists because we hit the failure in production and fixed it. Follow the order and you skip all of them.

The journey at a glance

Most of the work — and all of the decisions that matter — happen on your laptop, on the left.

flowchart LR
  subgraph Laptop["Your laptop — this guide"]
    direction TB
    A["1. Dockerfile
+ lockfile in sync"] --> B["2. Plan data
volume or database"] B --> C["3. List env vars
+ secrets"] C --> D["4. docker build + run
test it locally"] end D --> E["git push"] E --> F["Mezzanine
workflow + env + volume + port"] F --> G["Box
build to run to healthcheck"] G --> H["Live on your domain"]

📷 Screenshot — the Deploy → Workflows page in Mezzanine (the destination)


1. Decide these first

Write down the answer to each before you touch the Dockerfile. Three of our worst incidents came from skipping one of these.

DecisionWhy it mattersWhere it bit us
What port does the app listen on?It must equal Mezzanine’s Container port field.A site published 8092:3000 but nginx served :80curl error 56, deploy “failed”.
Is it stateful? (writes a DB, uploads, any file)Stateful apps must get a named volume.A database lived in a throwaway volume → wiped on a routine cleanup.
SQLite or a database server?SQLite → a volume. Postgres/MySQL → provision the DB + DATABASE_URL first.A Prisma app shipped with no DATABASE_URL → every request 500’d.
Which env vars / secrets?They must be set before first boot.next-auth threw UntrustedHost 500 because AUTH_TRUST_HOST was unset.
Public domain + which box?DNS → box → nginx → container port.We debugged the wrong box because DNS pointed elsewhere.

2. Prepare your repo on your laptop

2.1 A Dockerfile that builds reliably

Use a multi-stage, non-root build, and make the dependency install resilient:

# Install deps — fall back to `npm install` when the lockfile drifts.
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund || npm install --no-audit --no-fund

Always EXPOSE the real port and provide a health endpoint (see 2.4).

2.2 Keep your lockfile in sync

After any dependency change, regenerate and commit the lock:

Terminal window
npm install # updates package-lock.json
git add package-lock.json && git commit -m "sync lockfile"

2.3 .gitignore — secrets out, large media handled

# Never commit secrets
.env
.env.*
# Large media — GitHub hard-rejects files over 100 MB
*.mp4
*.mov
# ...but DO ship the small clips your app actually needs:
!public/assets/hero-*.mp4

2.4 Add a real health endpoint

Expose a route that returns 200 and ideally touches your database, e.g. GET /api/health. Mezzanine’s healthcheck needs a 200 — and pointing it at a DB-backed route means a broken database actually fails the deploy instead of going live silently.

2.5 Test the exact production shape on your laptop

If it doesn’t build and run on your laptop, it won’t in Mezzanine.

flowchart LR
  build["docker build -t myapp ."] --> run["docker run -p 8080:PORT
-e ENV... -v myapp-data:/app/data myapp"] run --> check{"curl :8080/api/health
= 200?"} check -->|No| fix["read logs, fix, rebuild"] --> build check -->|Yes| stop["docker rm -f, run again
data still there?"] stop --> ready["Ready for Mezzanine"]
Terminal window
docker build -t myapp .
docker run -d --name myapp -p 8080:3000 \
-e NODE_ENV=production -e DATABASE_URL=... -e AUTH_SECRET=... \
-v myapp-data:/app/data myapp
curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/health # expect 200
# Prove persistence: destroy + recreate, data must survive
docker rm -f myapp && docker run -d --name myapp -p 8080:3000 -v myapp-data:/app/data myapp

If the data survives that destroy/recreate on your laptop, it will survive a redeploy in Mezzanine.


3. Plan your data — the rule that matters most

flowchart TD
  Q{"Does the app
store anything?"} -->|No| S["Stateless — nothing to persist"] Q -->|"SQLite / files on disk"| V["Named volume at the data dir
-v app-data:/app/data"] Q -->|"Postgres / MySQL"| D["Provision the DB first
then DATABASE_URL, then migrate"] V --> R["Back up before every redeploy"] D --> R

SQLite apps: one named volume at the data directory. Database apps: stand up Postgres first, create a database + user, build the DATABASE_URL, and run migrations — the image’s prisma generate only builds the client; it never creates tables. Run prisma migrate deploy against the real DB before first use.


4. Prepare your environment variables

Collect every runtime value now. They go into the deploy’s Environment field — never into the image, never into git.

App typeRequired env
next-auth (v5)AUTH_TRUST_HOST=true (fixes UntrustedHost 500 behind a proxy), AUTH_SECRET (openssl rand -base64 32), AUTH_URL=https://yourdomain
Database appDATABASE_URL — and run migrations once
CommonNODE_ENV=production, PORT, SMTP_*, etc.

5. Pre-flight checklist

Before you push, confirm:

  • docker build succeeds from a clean checkout
  • package-lock.json committed and in sync
  • Container runs locally and /api/health returns 200
  • Data survives a docker rm -f + recreate with the same -v volume
  • Every env var / secret listed (and not committed)
  • EXPOSE port noted — you’ll type it into Mezzanine’s Container port
  • Large media excluded from git (or whitelisted, < 100 MB)
  • Database provisioned + migrated (if applicable)

6. Deploy in Mezzanine

Now hand it over. In the panel:

flowchart LR
  N["New deploy workflow
repo + branch"] --> P["Set Container port
= your EXPOSE"] P --> E["Add Environment vars
AUTH_*, DATABASE_URL..."] E --> M["Mount a volume
app-data:/app/data"] M --> HC["Healthcheck URL
a 200 path, e.g. /api/health"] HC --> RUN["Run → build, run, verify"]
  1. Deploy → New workflow, point it at the repo + branch. (Mezzanine auto-generates a Dockerfile only if you don’t commit one — committed Dockerfiles are used as-is.)
  2. Set the Container port to your EXPOSE value.
  3. Add all Environment variables from step 4.
  4. Mount the data volume so redeploys keep their data.
  5. Set the Healthcheck URL to a 200 path.
  6. Run, watch the live terminal, and verify the app + its data.

📷 Screenshot — the deploy workflow form: container port, env vars, volume, healthcheck

📷 Screenshot — the live run terminal streaming build → run → healthcheck


7. Do’s and Don’ts

✅ Do❌ Don’t
Mount a named volume for any dataRely on a Dockerfile VOLUME for persistence
Back up before every redeploy / migrationRun docker volume prune / system prune --volumes on a box with orphaned data
Set env + DB before the first deployPut a docker compose down -v in a deploy script (-v deletes volumes)
Healthcheck a 200 endpointAssume / returns 200
Keep large media out of gitCommit secrets or .env
Match Container port to EXPOSERedeploy a recovered app before its run-step mounts the volume

8. When something breaks — quick map

SymptomCauseFix
npm ci ... Missing @emnapi/... from lock fileLockfile drift across platformsResilient install (2.1)
[auth][error] UntrustedHost 500next-auth behind a proxyAUTH_TRUST_HOST=true (4)
prisma.user.count() invalid 500No / wrong DATABASE_URL, no migrationsProvision DB + migrate (3)
Healthcheck fails on 307/ redirects (auth/i18n)Healthcheck a 200 path (2.4)
App shows the setup screen after a deployData went to a fresh anonymous volumeNamed volume + recover the old one (3)
curl error 56 at healthcheckContainer port ≠ app portMatch the port (1)

For panel/install issues, see Troubleshooting. For how the deploy pipeline runs under the hood, see Deploy pipeline.