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. Your process starts once and stays up, listening on a port for requests. It is not a function invoked per-request — for a per-request, scale-to-zero handler, use a Serverless Function instead. 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: queued → building → image_ready → rolling_out → ready (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. By default 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.

Monorepos: build from a subdirectory

Monorepos are natively supported. Set the app's root_directory (its build subdirectory) — for example apps/web or packages/backend — and the build runs from that subdirectory: that folder's package.json / Dockerfile becomes the build root, and COPY paths are relative to it. Ship your whole repo (or subtree) and point root_directory at the app you want — no rewriting COPY paths and no vendoring.

Set it any of these ways:

  • CLI on create — ngris apps create <name> --type managed --root-dir apps/web.
  • CLI on an existing app — ngris apps settings --app <uuid> --root-dir packages/backend.
  • API — a root_directory field on POST /v1/applications and PUT /v1/applications/{uuid}.
  • Dashboard — the app's Settings pane.

An empty root_directory means the repo root — the default, unchanged behavior. The paths below still apply within the chosen subdirectory (a COPY ../… that escapes it still fails).

Alternative: flatten the prefix (no root_directory)

If you'd rather not set root_directory and instead zip just the app, watch the prefix. When your Dockerfile was written to build from the root of a monorepo (e.g. COPY myapp/web/… run from the repo root) but you upload only 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

Drop the prefix so paths are relative to the zip root (or just set root_directory and keep the Dockerfile as-is):

# 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/ ./

Alternative: vendor local modules into the zip

If you flatten instead of using root_directory, and 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 (or set root_directory to a subtree that already contains the sibling):

# 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? Create a managed MariaDB database under Storage → Databases and connect it from the app's Environment tab (Connect database). ngris gives the app DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD and a ready-to-use DATABASE_URL (or the same names behind a prefix you choose); they show in the variable list as locked rows. See 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 1–65535). 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.

Outbound network access

By default a running app has no internet access. It can resolve DNS and reach the managed databases it is connected to, and nothing else. Calls to an outside API, a webhook URL, or a package registry at runtime time out.

On a paid plan you can turn on outbound HTTP and HTTPS for one app by setting this environment variable:

NGRIS_EGRESS_HTTPS=on
  • The app can then open connections on TCP ports 80 and 443 to public IPv4 addresses.
  • Every other port stays blocked. For example, an external PostgreSQL on 5432 or SMTP on 587 can’t be reached this way.
  • Private and internal ranges stay blocked even on ports 80 and 443: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 100.64.0.0/10, link-local 169.254.0.0/16 (including cloud metadata), loopback, multicast and reserved addresses. The app can’t reach other apps, Ngris’s internal network, or the cluster.
  • IPv6 destinations are not reachable.
  • The value must be exactly on (case doesn’t matter). true, 1 or any other value leaves outbound access off.
  • The variable has to apply to the deploy that’s running, so scope it to Production or Both. Saving it on a running app rolls the app out with the change; deleting it, or setting it to anything else, turns outbound access off again.
  • Outbound requests leave from Ngris infrastructure. There is no fixed source IP to allowlist.
Plan changes. On a plan without outbound access, setting the variable does nothing. If an account loses it (a downgrade, for example), Ngris removes outbound access from its running apps within about a minute, with no redeploy. After upgrading, save the variable again or redeploy to turn it on.

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. If you need a database, create one under Storage → Databases and connect it from the app's Environment tab (Connect an app).
  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.