Backend Apps

Backend Apps

A Backend App is a long-running server that Ngris builds in an isolated sandbox and runs in-cluster, reached over global HTTPS through an endpoint. Ship a Go, Node, or Python service — or any stack that comes with a Dockerfile — and Ngris compiles it into a signed, scanned, digest-pinned image and runs it for you. No agent, no CLI, no server of your own to operate.

It runs a long-running backend — there is no per-request serverless / FaaS runtime. Your process starts once and stays up, listening on a port for requests. It is not a function invoked per-request. If you want to host built files (a site or SPA) with no server, use a Static App instead.

Backend Apps come in two shapes, both carrying a backend Ngris runs:

  • Backend only (type managed) — a monolith server; every path is served by your backend.
  • Full-stack (type fullstack) — a static front end on the edge plus a path-routed backend (a Vercel-style split: /api/* hits the backend, everything else serves the static front end). See Full-stack apps.

Backend Apps require the application_hosting entitlement, plus managed-backend availability on your plan (the managed_backends entitlement — the on/off master gate). Without it, create returns 403 Managed backends are not available on your plan.

How a deploy works

Every deploy takes your source from code to a live URL through the same pipeline:

  • Upload a .zip of your source, or connect a Git repo so every push redeploys.
  • Ngris auto-detects your language — Go, Node, or Python — and builds it onto an Ngris-pinned minimal base; or, if your source has a Dockerfile, it builds that instead.
  • The build runs in a hardened, gVisor-sandboxed job and produces a signed, vulnerability-scanned, digest-pinned container image.
  • Ngris runs that image in a per-app sandboxed pod and serves it via its endpoint. Because the image is immutable and pinned to its digest, rolling back to an earlier version is instant.

A deploy moves through a series of statuses you can watch live: queuedbuildingimage_readyrolling_outready (or failed). Open Deploys → the deploy → Log and the build log tails live while it runs, so you can see exactly what ran and why a build broke.

Your build context is the zip root

This is the single most common thing that trips up a first backend deploy, so read it before you zip.

The Docker build context is the root of your uploaded zip. Every Dockerfile COPY path is relative to that root. Your Dockerfile must sit at the root of the zip, and the zip must be self-contained: no COPY ../…, no paths that assume a parent or monorepo directory, and no reliance on sibling directories that aren’t inside the zip. If a path points outside the context, the build fails.

The monorepo-prefix trap

If your Dockerfile was written to build from the root of a monorepo (e.g. COPY myapp/web/… run from the repo root) but you zip just the app, the myapp/ prefix no longer exists inside the context and the build fails with something like:

failed to compute cache key: lstat /myapp/web/package.json: no such file or directory

The fix is to drop the prefix so paths are relative to the zip root:

# BEFORE — assumes a monorepo root that isn't in the zip
COPY myapp/web/package.json ./
COPY myapp/web/ ./

# AFTER — relative to the zip root (the build context)
COPY web/package.json ./
COPY web/ ./

Local modules must be vendored into the zip

If your app depends on a local module via a replace directive — a monorepo sibling like a shared/ package that lives outside the app directory — that sibling is not in the context, so the build can’t resolve it. Copy the module into the zip and point the replace at the in-zip copy:

# zip layout: put the sibling INSIDE the app dir before zipping
#   myapp/
#     go.mod
#     main.go
#     shared/        <-- vendored copy of the local module

# BEFORE — replace points OUTSIDE the zip (a monorepo sibling)
# go.mod:  replace example.com/shared => ../shared

# AFTER — vendor it in, and point the replace at the in-zip copy
COPY . ./
RUN go mod edit -replace=example.com/shared=./shared \
 && go build -o /app/server ./...

The rule is simple: if the build needs it, it must be inside the zip. The build context has no access to your machine, your parent directories, or the rest of a monorepo — only what you uploaded.

What to put in the zip

Ship source, not build output — the build reinstalls and rebuilds your dependencies in the sandbox. A lean zip builds faster and avoids surprises. Exclude:

  • node_modules and any vendored dependency trees — the build installs them fresh (npm ci, go mod download, pip install).
  • Compiled or dist / build output — it’s regenerated inside the image.
  • .git — history isn’t needed to build.
  • Any .env with secrets — a committed .env can override the platform-injected config, and secrets don’t belong in an image. Set them as environment variables instead.

The cleanest way to enforce all of the above is a .dockerignore at the zip root:

node_modules
dist
build
.git
.env
*.log
Upload cap: 100 MB per deploy. A source zip that stays under the cap is a sign it’s lean — if you’re near the limit, you’re almost certainly shipping node_modules or build output you should exclude.

Environment variables & secrets

Set config, tokens, and connection strings under the app’s Environment settings (or the API). Ngris injects each variable into the running container’s process environment under its exact name — your app reads it with the normal env API of its language (os.Getenv("DATABASE_URL"), process.env.DATABASE_URL, os.environ["DATABASE_URL"]).

  • Mark sensitive values as Secret — they’re encrypted at rest, masked in the API and dashboard (write-only), and decrypted only inside the pod at start-up.
  • Changes take effect on your next deploy or rollout — trigger one with a push, a re-upload, or by re-activating the current deploy.
  • Names must match [A-Za-z_][A-Za-z0-9_]*.
curl -X PUT "https://api.ngris.com/v1/applications/{uuid}/env" \
  -H "X-API-KEY: <your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{"name":"LOG_LEVEL","value":"info","is_secret":false}'

Need a database? A Backend App can add a fully-managed MySQL-compatible database that auto-injects its connection (DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD, and a ready-to-use DATABASE_URL) as environment variables — nothing to install or copy. See App databases.

The port your app must listen on

The platform routes traffic to the port you configure for the app, so your server must listen on that port. Bind it on all interfaces — 0.0.0.0:<port>, not 127.0.0.1 — or requests won’t reach it.

Set the port in the app’s Runtime settings (or PUT /v1/applications/{uuid}/runtime with port, valid 165535). If you don’t set one, the default expected port is 8080.

Ports below 1024 won’t bind. The container runs as a non-root user with all Linux capabilities dropped, so it cannot bind a privileged port. Listen on 8080, 3000, 8000, or any port ≥ 1024 — never 80 or 443. HTTPS is terminated for you at the endpoint.

Other runtime knobs live alongside the port: a health check path (health_check_path, default /), CPU & memory (cpu_millis 50–4000, memory_mb 64–8192; requests equal limits for Guaranteed QoS), and replicas (min_replicas, max_replicas).

Filesystem: read-only root, writable /tmp

The container runs with a read-only root filesystem. The only writable location is /tmp, a small scratch mount (~256 Mi).

  • Write any temp files — caches, uploads in flight, scratch output — only to /tmp. Point your framework’s temp dir there (TMPDIR=/tmp) if it defaults elsewhere.
  • Don’t try to write to the app directory, the working directory, or anywhere else at runtime — those writes fail. Bake everything the app needs at run time into the image at build time.
  • /tmp is ephemeral and per-pod — it’s wiped on restart and not shared between replicas. Persist real data in a managed database or an external store, never on the local filesystem.

Deploy a backend in 5 steps

Dashboard walkthrough

  1. Create the app. Open Applications → New and pick type Backend (or Full-stack if it also serves a static front end).
  2. Set env vars & secrets. Add your config under Environment, marking sensitive values as Secret. Add a managed database from the Database tab if you need one.
  3. Upload or connect. Choose Upload and drop your source zip (self-contained, ≤ 100 MB), or Connect Git so every push redeploys.
  4. Watch the build. Open the deploy’s Log — it tails live — and wait for the deploy to reach ready.
  5. It’s live. The app serves at its endpoint URL. Add a custom domain any time — TLS is provisioned automatically.

API — create a backend app

curl -X POST "https://api.ngris.com/v1/applications" \
  -H "X-API-KEY: <your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{"name":"my-api","type":"managed","source":"upload"}'

Use "type":"fullstack" for a static front end plus a path-routed backend, or "source":"git" with a repo_url to connect a repo instead of uploading.

API — upload the source & deploy

curl -X POST "https://api.ngris.com/v1/applications/{uuid}/deploys" \
  -H "X-API-KEY: <your_api_key>" \
  -F "file=@my-api-src.zip" \
  -F "version=v1.0.0"   # optional label

The build runs asynchronously. Poll GET /v1/applications/{uuid}/deploys until the newest deploy reaches ready, or stream the build log with GET /v1/applications/{uuid}/deploys/{deploy_uuid}/logs. Read live runtime status — instances, ready replicas, restarts — from GET /v1/applications/{uuid}/status.

Something wrong?

A build failing or the app not coming up? The Log for a failed deploy shows exactly where the build stopped — a bad COPY path, a missing dependency, or a server that never bound its port. Walk the fixes in Deploy troubleshooting.
Iris