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.
| Decision | Why it matters | Where 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 :80 → curl 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-fundAlways 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:
npm install # updates package-lock.jsongit 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-*.mp42.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"]
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 myappcurl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/api/health # expect 200# Prove persistence: destroy + recreate, data must survivedocker rm -f myapp && docker run -d --name myapp -p 8080:3000 -v myapp-data:/app/data myappIf 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 type | Required 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 app | DATABASE_URL — and run migrations once |
| Common | NODE_ENV=production, PORT, SMTP_*, etc. |
5. Pre-flight checklist
Before you push, confirm:
-
docker buildsucceeds from a clean checkout -
package-lock.jsoncommitted and in sync - Container runs locally and
/api/healthreturns200 - Data survives a
docker rm -f+ recreate with the same-vvolume - Every env var / secret listed (and not committed)
-
EXPOSEport 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"]
- 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.)
- Set the Container port to your
EXPOSEvalue. - Add all Environment variables from step 4.
- Mount the data volume so redeploys keep their data.
- Set the Healthcheck URL to a
200path. - 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 data | Rely on a Dockerfile VOLUME for persistence |
| Back up before every redeploy / migration | Run docker volume prune / system prune --volumes on a box with orphaned data |
| Set env + DB before the first deploy | Put a docker compose down -v in a deploy script (-v deletes volumes) |
Healthcheck a 200 endpoint | Assume / returns 200 |
| Keep large media out of git | Commit secrets or .env |
Match Container port to EXPOSE | Redeploy a recovered app before its run-step mounts the volume |
8. When something breaks — quick map
| Symptom | Cause | Fix |
|---|---|---|
npm ci ... Missing @emnapi/... from lock file | Lockfile drift across platforms | Resilient install (2.1) |
[auth][error] UntrustedHost 500 | next-auth behind a proxy | AUTH_TRUST_HOST=true (4) |
prisma.user.count() invalid 500 | No / wrong DATABASE_URL, no migrations | Provision DB + migrate (3) |
Healthcheck fails on 307 | / redirects (auth/i18n) | Healthcheck a 200 path (2.4) |
| App shows the setup screen after a deploy | Data went to a fresh anonymous volume | Named volume + recover the old one (3) |
curl error 56 at healthcheck | Container port ≠ app port | Match the port (1) |
For panel/install issues, see Troubleshooting. For how the deploy pipeline runs under the hood, see Deploy pipeline.