Troubleshooting Deploys

Troubleshooting deploys

Most deploy problems fall into a handful of shapes: an upload that’s too big, a Dockerfile whose paths don’t match the build context, a container that starts but can’t reach its config or database, or a preview URL that isn’t enabled. This guide walks each one — what you see, why it happens, and how to fix it. It covers static apps, managed backends, and full-stack apps.

Read the logs first. Every build’s full output is captured — success or failure. For any failed deploy, open the deploy’s Log on the Deploys page (it tails live while the build runs) before anything else; the exact error is almost always in there. See Reading deploy status & logs below.

“Upload too large or malformed”

Symptom. Your upload is rejected with upload too large or malformed (max 100MB) and an HTTP 413.

Why. Either your .zip exceeds the per-deploy upload cap of 100 MB, or it isn’t a valid multipart/form-data request with a real .zip in the file field. Ngris parses the multipart body and fails both cases with the same message.

Fix. Keep the zip lean — ship source only. The build regenerates dependencies and build output for you, so you don’t need to (and shouldn’t) include them. Exclude:

  • node_modules/ — reinstalled during the build.
  • Build output (dist/, build/, .next/) — regenerated during the build.
  • .git/ — not needed by the build.
  • Large binaries, media, and archives that aren’t part of your source.

Add a .dockerignore (for backend builds) and prune the zip before uploading. A source-only zip is almost always a few MB, well under the limit.

# zip source only, excluding the heavy regenerated dirs
zip -r app.zip . \
  -x '*/node_modules/*' -x '*/dist/*' -x '*/build/*' \
  -x '*/.git/*' -x '*/.next/*'
Note. The current per-deploy limit is 100 MB. Very old client or proxy versions capped uploads smaller; if you hit a lower ceiling than 100 MB, update your client. If you’re legitimately over 100 MB after pruning, connect a git repo instead — git deploys don’t go through the upload cap.

Build fails: COPY — “no such file or directory”

Symptom. A backend build fails partway through with something like lstat /var/lib/.../myapp/web/package.json: no such file or directory, or any COPY step reporting a path that isn’t found.

Why. Your Dockerfile’s COPY paths don’t match the build context. The build context is the root of your uploaded zip, and every COPY <src> <dst> reads <src> relative to that root. A Dockerfile written for a monorepo build — run from the repo root, copying myapp/web/… — breaks when you zip just the app, because there is no myapp/ directory inside the context.

Fix. Make the paths context-root-relative by dropping the leading directory prefix that only exists in the monorepo.

# BEFORE — assumes the repo root is the context (myapp/ exists)
COPY myapp/web/package.json myapp/web/package-lock.json ./
RUN npm ci
COPY myapp/web/ ./

# AFTER — context is the root of THIS app's zip (no myapp/ prefix)
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./

If your index.html or package.json sits at the zip root (you zipped from inside the app directory), drop the web/ prefix too — use COPY . ./ or COPY package.json ./. When in doubt, list the zip (unzip -l app.zip) and match each COPY source to a path you actually see there.

Build fails: a local dependency is missing

Symptom. The build fails on a step like COPY shared/ ./shared/ (“no such file or directory”), or go build / npm can’t resolve a workspace package that isn’t on the public registry (a replace target, a Go module in a sibling directory, an npm workspace).

Why. Your app depends on a local module that lives outside the zip — a sibling package in your monorepo. The build context only contains what’s in your uploaded zip, so that sibling simply isn’t there to copy or resolve.

Fix. Vendor the dependency into the zip and point your replace directive (or workspace config) at the in-zip copy, so the build is fully self-contained.

# include ./shared inside the zip alongside your app, then in the Dockerfile:
COPY shared/ ./shared/
COPY go.mod go.sum ./
# rewrite the module path to the in-zip copy (was ../shared in the monorepo)
RUN go mod edit -replace=github.com/acme/shared=./shared
RUN go mod tidy && go build -o /app ./...

The same principle applies to Node workspaces (copy the sibling package into the zip and reference it by a relative file: path) and to any private tarball a public install can’t reach. Rule of thumb: if docker build wouldn’t succeed from a fresh clone of just your zip’s contents, it won’t succeed here either.

App builds but crashes on start / keeps restarting

Symptom. The build succeeds and the deploy reaches image_ready, but the running backend never becomes ready — it starts, exits, and restarts in a loop, and the app’s status shows restarts climbing.

Why. The container process is failing at start-up. The usual causes are a missing required environment variable, a config file the app expects that isn’t there, or the app trying to write to a path it’s not allowed to.

Fixes.

  • Set every required env var. Add your app’s own config keys under the app’s Environment panel (or the API). A backend’s environment variables are delivered to the running container, so anything your process reads at start-up must be set there.
  • Only /tmp is writable. The container runs with a read-only root filesystem and a single writable /tmp. Write temp files, caches, and scratch data to /tmp — not to the working directory, /app, or anywhere else. An app that tries to write a lockfile, a SQLite file, or a log into a read-only path will crash on boot.
  • Read config from env vars, not files. Managed pods inject configuration as environment variables, not mounted files. If your app insists on a file on disk (say, a key or a JSON config), pass the content as an env var and have your start-up code write it to /tmp before use, then point the app at that path.

Open the deploy’s Log and the app’s runtime logs in the dashboard for the exact panic or error — a crash-on-start almost always prints the missing variable or the read-only path it tried to write.

App runs but the URL returns 502 / no response

Symptom. The pod is up (no crash loop), but requests to the app’s URL time out or return a 502.

Why. Ngris routes traffic to the port you configured on the app, but your server isn’t listening on it — either it’s bound to a different port, or it’s bound to 127.0.0.1 (loopback only) instead of all interfaces, so the edge can’t reach it.

Fix. Bind your server to 0.0.0.0 on the port you set in the app’s Runtime settings, and make sure the two match.

# Node — listen on 0.0.0.0 and the configured port
app.listen(process.env.PORT || 8080, "0.0.0.0")

# Go — bind all interfaces, not localhost
http.ListenAndServe("0.0.0.0:8080", handler)

A common mistake is a framework default of localhost — explicitly set the host to 0.0.0.0. If your app reads its port from an env var, set that env var and the app’s configured Port to the same value.

Database: can’t connect / “Access denied”

Symptom. Your backend can’t connect to its managed database — a connection refused, a bad-DSN parse error, or Access denied for user.

Why. Almost always the wrong variable or the wrong DSN format. When the database is ready, Ngris injects these into your app and redeploys it:

  • DB_HOST, DB_PORT, DB_NAME, DB_USER — plain values.
  • DB_PASSWORD — a secret (encrypted at rest, delivered to the running container, never shown to you).
  • DATABASE_URL — a secret URL of the form mysql://user:pass@host:port/db.

Fix. Use the right variable for your driver, and never hard-code the password — you don’t see it, and it can rotate; read it from the injected env at runtime.

  • Libraries that accept a URL (many Node and Python clients) can use DATABASE_URL as-is.
  • The Go go-sql-driver/mysql driver does not take a mysql:// URL — it needs a DSN of the form user:pass@tcp(host:port)/db. Build it from the individual parts rather than passing DATABASE_URL straight in.
// Go — build the go-sql-driver DSN from the injected parts (do NOT pass DATABASE_URL)
dsn := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s",
    os.Getenv("DB_USER"), os.Getenv("DB_PASSWORD"),
    os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_NAME"))
db, err := sql.Open("mysql", dsn)

If you see Access denied, confirm you’re reading DB_PASSWORD from the environment (not a stale or hard-coded value) and that you’re connecting to DB_NAME — the injected user is least-privilege and scoped to exactly that schema. See Managed databases for the full connection details.

Database stuck at “Provisioning…”

Symptom. The app’s Database tab shows provisioning and doesn’t advance to ready.

Why. A transient error in the one-shot provisioning job (which creates your schema and user). Provisioning runs independently of deploys — it doesn’t need a deploy to finish — and a failed attempt marks the binding for retry rather than leaving a half-built database behind.

Fix. Use Retry on the app’s Database tab. The provisioning DDL is idempotent, so retrying is safe and self-heals the common transient cases — no partial database is left behind. If it stays stuck after a retry, contact support with your app ID.

Preview URL returns 404

Symptom. You open a deploy’s <deploy-uuid>.<domain> preview URL and get a 404 / “not available” instead of the site.

Why. Previews are opt-in and off by default. A preview host is public and unauthenticated — anyone with the UUID can reach it — so Ngris never auto-mints one. Until you explicitly enable the preview for that deploy, the edge doesn’t route its URL, and you get a 404.

Fix.

  • Enable the deploy’s Preview toggle on the Deploys page, or call POST /v1/applications/{uuid}/deploys/{deploy_uuid}/preview.
  • Confirm you’re using the deploy UUID as the leftmost label — not the application UUID.
  • Confirm the deploy is ready or superseded. A queued, building, or failed deploy has no served content to preview.

Once enabled, the preview_url is returned by the enable call and listed next to the deploy. See Preview deploys for how previews work.

Reading deploy status & logs

Every deploy carries a status. Knowing what each one means tells you whether to wait, read the build log, or fix your source:

  • queued — accepted, waiting for a build worker to pick it up.
  • processing — the worker has claimed it and is unpacking/preparing the source.
  • building — running your install + build (or building your Dockerfile into an image, for a backend).
  • image_ready — the backend image is built, scanned, and signed; the runtime is about to roll it out.
  • rolling_out — the new backend pods are starting; the version goes live only once they prove ready.
  • ready — live and serving. This is the healthy terminal state.
  • failed — the build or rollout failed. Open the build log for the exact error.
  • superseded — a previously-live deploy that a newer version replaced (still rollbackable while its artifact is retained).

Static apps use queued → processing → ready (or failed); backends add the finer building → image_ready → rolling_out steps before ready.

Find the build log as the Log link next to any deploy on the Deploys page — it tails live while the build runs — or fetch it directly:

curl "https://api.ngris.com/v1/applications/{uuid}/deploys/{deploy_uuid}/logs" \
  -H "X-API-KEY: <your_api_key>"

For a running backend, the app’s runtime logs (separate from the build log) show what your process prints at start-up and per request — the place to look for a crash-on-start or a 502.

Still stuck? Contact support with your deploy ID and the build log — those two pin down almost any deploy failure fast.
Iris