Static Apps

Static Apps & deploys

A Static App hosts a website or single-page app on the Ngris edge — no server, no agent, no CLI. Upload a .zip of your built site or connect a GitHub/GitLab repo, and Ngris serves it over global HTTPS with automatic TLS. Every deploy is immutable and versioned, so rollback is an instant pointer flip.

Two pieces work together: an Application is the content (your files, from an upload or a git repo); an agentless Endpoint points at it and gives it a public URL. An app can have several endpoints, one of them its primary. Unlike a tunnel, there is no agent to run — the content lives on the edge.

Ngris builds your site. Push (or upload) your source and Ngris auto-detects your framework — Vite, Next.js, Astro, SvelteKit, Create React App, Nuxt, Gatsby, Angular, Hugo — runs install + build in an isolated per-build sandbox, and serves the output. Already-built static files (an index.html at the root, no framework) are served as-is with no build step. To use your own commands or output folder, set Build settings.

Static Apps require the application_hosting entitlement on your plan. Bandwidth counts against your account's single monthly data-transfer allowance (the same pool as tunnels); over the cap, requests are served a 429 until the period resets.

Beyond static: full-stack apps. An app can also carry a backend that Ngris runs in-cluster (a fullstack app = static front end + a path-routed backend). Push a Go, Node, or Python service (auto-detected) or any Dockerfile (any language or framework, a Next.js server included) and Ngris builds it in a sandboxed job and runs it on the edge behind the same policy engine. It runs a long-running backend; for a per-request, scale-to-zero handler instead, see Serverless Functions. A full-stack app can also use a database.

Quick deploy (one call)

The fastest path from code to a live URL creates the app, its first deploy, and the public endpoint in a single request — and returns the live URL.

Dashboard

Open Applications → New, pick Upload files or Connect Git, and follow the wizard. When the build is ready the app is live at its generated URL; add a custom domain any time.

API — upload a built site

curl -X POST "https://api.ngris.com/v1/applications/quick-deploy" \
  -H "X-API-KEY: <your_api_key>" \
  -F "name=my-site" \
  -F "file=@dist.zip" \
  -F "wait=true"

API — connect a git repo

curl -X POST "https://api.ngris.com/v1/applications/quick-deploy" \
  -H "X-API-KEY: <your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{"name":"my-site","repo_url":"https://github.com/acme/site","branch":"main"}'

The response includes the live url. Pass wait=true to block up to 90 seconds until the first build is live; otherwise the URL is created immediately and starts serving within a few seconds as the build finishes. See the Quick Deploy API reference for the full response.

Deploy from an upload

Build your site locally, zip the output directory, and upload it. Each upload is a new immutable deploy; the first successful build goes live automatically.

  • Run your build (npm run build, vite build, Hugo…) and zip the output folder (dist/, build/, public/).
  • Make sure index.html sits at the root of the zip (or set Publish directory to the subfolder that holds it).
  • Max upload size is 100 MB.
curl -X POST "https://api.ngris.com/v1/applications/{uuid}/deploys" \
  -H "X-API-KEY: <your_api_key>" \
  -F "file=@dist.zip" \
  -F "version=v2.1.0"   # optional label

The build runs asynchronously; poll GET /v1/applications/{uuid}/deploys until the newest deploy reaches ready. Each deploy exposes a display_version: your optional version label if set, else the git commit short SHA (git deploys) or v{n} (uploads).

Git push-to-deploy

Connect a GitHub or GitLab repo and every push to the configured branch redeploys automatically. On connect, Ngris runs the first deploy and generates a webhook secret.

Import from GitHub (no token, no webhook to add)

In Applications → New, choose Connect Git, then Connect GitHub. GitHub asks you to install the ngris GitHub App and choose which repositories it may read (all, or only the ones you pick). You come back to the wizard with your repositories listed; pick one and create the app. Every push to its branch builds and deploys automatically. There is no token to paste and no webhook to add.

  • ngris only gets read access to the repositories you granted. Each build uses a short-lived token for that one repository, which is never stored.
  • Each member signs in with their own GitHub account and sees only the repositories they can access on GitHub, even when the organization granted the app more. A teammate who didn't install the app clicks Sign in with GitHub in the picker.
  • An app keeps deploying while the person who connected its repository can still read it on GitHub. If they lose access (removed from the repository or the organization, or they disconnect GitHub), pushes are skipped and the app shows why; someone with access reconnects it.
  • To grant more repositories (or a GitHub organization), choose Add a GitHub account or organization… in the account list, or change the app's repository access in GitHub under Settings → Applications → ngris → Configure.
  • If someone uninstalls the app or removes a repository's access in GitHub, that app's next build fails with a message saying what to fix.
  • An organization that requires approval for new apps sends your install request to its owners; connect again once they approve.
  • Once a repository is imported, the app belongs to your ngris account: any member who can manage applications can deploy it and change its build settings, and so can see what the build sees. Import only repositories you are happy to share with the account's members.

Over the API: list your connected accounts with GET /v1/git/installations, the repositories your GitHub account can access in one with GET /v1/git/installations/{uuid}/repos?q= (a 409 with github_auth_required means sign in with GitHub in the dashboard first), then create the app with {"source": "git", "git_installation_uuid": "<installation_uuid>", "repo": "owner/name"} (the branch defaults to the repository's default branch). DELETE /v1/git/installations/{uuid} disconnects an account from ngris; its apps stop building until it is connected again.

Or connect with a URL (public repos, GitLab, or a token)

1. Connect the repo

Create the app with source: "git" (or use quick-deploy with repo_url). For a private repo, pass a git_token (a GitHub PAT or GitLab token with read access). The branch must be a valid git branch name, as it appears in your repository (for example main or feature/login, at most 255 bytes); anything else is refused with “That isn't a valid branch name. Enter it as it appears in your repository, like main or feature/login.”

2. Register the webhook

Fetch the webhook details with GET /v1/applications/{uuid}/webhook (or copy them from the app's Push to deploy panel: the secret shows once you click Reveal), then:

Provider Where Fields
GitHub Repo → Settings → Webhooks → Add webhook Payload URL = url, Content type = application/json, Secret = secret, event = “Just the push event”.
GitLab Settings → Webhooks URL = url, Secret token = secret, enable Push events.

Ngris verifies every delivery's signature (GitHub X-Hub-Signature-256 / GitLab X-Gitlab-Token) against the secret, and deploys the exact pushed commit. Rotate the secret any time with POST /v1/applications/{uuid}/webhook/regenerate — remember to update it in the provider afterward.

Change the branch, auto-deploy or token

A git app's Settings tab has a Push to deploy card: the Production branch, an Auto-deploy switch (off: pushes deploy nothing), and, for a repository connected with a URL, its Access token. The token box is always empty; leave it empty to keep the saved token, or type a new one to replace it. An app imported through the GitHub App has no token. Click Save Git settings. Over the API, send only the fields you change; only those are saved:

curl -X PATCH "https://api.ngris.com/v1/applications/{uuid}/git" \
  -H "X-API-KEY: <your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{"branch":"release","auto_deploy":false}'

The repository and the webhook stay as they are. The branch follows the same rule as on connect. To remove a saved token (a repository that became public, or a token you revoked), send {"git_token_remove": true} without a new git_token; sending both is refused (400), and an app imported through the GitHub App has no token to remove (409). The dashboard card has no remove action yet, so use the API for it.

Redeploy a git app

Redeploy on the app's Deploys tab builds the latest commit of the production branch, without a push. Use it while Auto-deploy is off, or to retry a failed build. It follows the app's deploy policy: with Require manual promotion on, it builds a candidate for you to promote. It always builds with the app's current build settings; for a managed, full-stack or function app, every Redeploy builds a fresh image. The same holds when ngris support redeploys an app for you. Over the API it is POST /v1/applications/{uuid}/redeploy (send activate=false for a candidate), and from the CLI ngris apps redeploy --app <uuid>.

Serving settings

Tune how files are served with PUT /v1/applications/{uuid} or the app's Serving settings panel:

  • Publish directory (publish_dir) — the folder your site is served from. For an upload of a site that is already built, that's the folder inside the archive (e.g. dist); leave it blank if index.html is at the archive root. When Ngris builds your site, leave it blank and set the Output directory instead. Don't set both to dist: your site would then be looked for in dist/dist.
  • Index document (index_document) — served for directory requests. Default index.html.
  • SPA fallback (spa_fallback) — serve the index for any unmatched path. Turn this on for React/Vue/Svelte apps that use client-side routing.
  • Custom 404 (not_found_path) — a document served for missing paths when SPA fallback is off.

Build settings

Ngris detects how to build your site. To use your own commands, open the app's Settings tab and fill in Build settings, or send them with PUT /v1/applications/{uuid}. A box left empty is detected as before.

  • Install command (install_command) — runs before the build, instead of the detected install. Example: npm ci --legacy-peer-deps.
  • Build command (build_command) — runs instead of the detected build. Example: npm run build -- --outDir site.
  • Output directory (output_dir) — the folder your build writes the site to, inside the project. Example: site. Everything in it is published.

Builds run on Node.js 24, with npm, pnpm or yarn (picked from your lockfile), and Hugo. There is no version picker. Ruby, Python and Go aren't available in a site build.

Saving doesn't rebuild. The next push, upload or Redeploy uses the new settings.

curl -X PUT "https://api.ngris.com/v1/applications/{uuid}" \
  -H "X-API-KEY: <your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{"build_command":"npm run build -- --outDir site","output_dir":"site"}'

Send "" to go back to detection. From the CLI: ngris apps settings --app <uuid> --build-command 'npm run build -- --outDir site' --output-dir site. You can also set them when you create the app (API, CLI or MCP), so its first build uses them.

Good to know

  • A command is one line of at most 1024 bytes: chain steps with &&. Line breaks, tabs and other invisible characters are refused.
  • The output directory is a folder inside the project: no leading / and no ..; set it to . for the project root itself.
  • With a Root directory set, the commands run inside it and the output directory is inside it too.
  • The output directory only counts when a build runs. For an upload of a site that is already built, use the Publish directory.
  • An empty box means “detect it”, so there is no “skip”: to skip the install, set the install command to true.
  • If Ngris finds no build in your project, a build command alone runs without an install and publishes the whole project. Set the install command and the output directory too.
  • Commands are shown in the build log. Don't type a token into a command: add it as a secret environment variable and write $NAME.
  • Quick deploy and the dashboard's New application page don't ask for build settings, so the first build detects everything. Set them in Settings, then Redeploy.
  • Settings aren't kept per deploy: every new build uses the current ones, branch previews included. Promoting or rolling back a deploy doesn't rebuild it.
  • A managed or full-stack app also has a start command: see Backend Apps. Functions and WordPress sites have no build settings.

Builds & environment variables

When your source contains a framework (a package.json with a build script, or a Hugo config), Ngris runs the build for you in a locked-down, single-use sandbox: no network except your package registry, no credentials, read-only root, hard CPU/memory/time limits. It installs dependencies, runs the build, and publishes the output directory it detects for your framework (dist/, build/, out/, public/…), or the commands and folder you set in Build settings.

Environment variables & secrets

Front-end builds need config — API URLs, publishable keys, feature flags. Add them on the app's Environment tab (or the API); each one is injected into your build with its exact name, so VITE_API_URL or NEXT_PUBLIC_TOKEN is available to vite build / next build as-is.

  • Mark sensitive values as Secret — they're encrypted at rest and never returned by the API or shown in the dashboard again (write-only).
  • An empty value keeps an existing secret's value: editing a secret shows an empty box, and saving it untouched keeps the secret. Type a new value to replace it. A secret can't be set to an empty value; delete it instead.
  • Changes apply on your next deploy; trigger one with a push or a re-upload.
  • Names must match [A-Za-z_][A-Za-z0-9_]*.

Production, preview or both

Each variable applies to one environment: Production, Preview or Both (the default; all in the API and CLI).

  • Preview covers branch previews only. A deploy waiting to be promoted (deployed with Deploy as preview or activate=false) gets the Production variables, because that is the build that goes live when you promote it.
  • A name can have a value for Both and its own value for one environment. The environment's own value wins there: with API_URL set for Both and for Preview, branch previews get the Preview value and production gets the Both value.
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":"VITE_API_URL","value":"https://staging-api.example.com","is_secret":false,"environment":"preview"}'

List them with GET /v1/applications/{uuid}/env (secret values masked; each variable shows its environment) or remove one with DELETE /v1/applications/{uuid}/env/{name}?environment=preview (without environment it removes the Both value). Removing a name that isn't set in that environment answers 404 “No variable with that name in the preview environment” and changes nothing.

From the CLI:

ngris env list --app <uuid>                                 # NAME  ENVIRONMENT  SOURCE  VALUE
ngris env set VITE_API_URL=https://staging-api.example.com --app <uuid> --env preview
ngris env pull --app <uuid> > .env                          # what production gets
ngris env rm VITE_API_URL --app <uuid> --env preview

See the CLI reference for every flag.

Build logs (live)

Every build’s full log is captured — success or failure — so you can see exactly what ran and why a build broke. Open a deploy’s Build log from the Deploys tab and it tails live while the build runs; or fetch the log directly (an X-Build-Status response header reports whether the build is still running):

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

Runtime logs (live)

Build logs show what happened while your app was being built; runtime logs show what your app prints while it is running — its own stdout/stderr. Reach for runtime logs when a deploy built fine but the app then crashes, crash-loops, or returns 500s at runtime (a panic, a bad connection string, a missing env var). They’re on the Runtime logs tab of the app’s detail page (live-tailing every few seconds), or fetch them directly:

curl "https://api.ngris.com/v1/applications/{uuid}/runtime-logs?tail=200" \
  -H "X-API-KEY: <your_api_key>"

The X-Runtime-Status response header reports the pod state (running, pending, failed, …). Add &previous=1 to read the previous (crashed) instance’s logs when the current pod has just restarted. Runtime logs apply to managed, fullstack, WordPress, and serverless-function apps — a static app is served straight from the edge with no running process, so it has no runtime logs.

Managed backends

A managed backend runs a long-running server on the Ngris edge — a Go, Node, or Python service, a PHP app, or any stack that ships with a Dockerfile. Create the app with type managed (backend only) or fullstack (static front end + a path-routed backend), from an upload or a connected git repo, exactly like a static app.

How a backend build differs from static. Instead of building to files, Ngris builds your Dockerfile into a container image in a sandboxed job, then signs it, scans it for vulnerabilities, and pins it to its digest. The running backend is gVisor-isolated and reached through a managed endpoint. Because each deploy's image is immutable and digest-pinned, rolling back to an earlier version is instant.

Managed backends require the managed_backends entitlement. They run a long-running process; for a per-request, scale-to-zero handler, use a Serverless Function instead.

Server-rendered frameworks (SSR)

Deploying a server-rendered JavaScript framework — Next.js (its default/SSR mode), Nuxt, SvelteKit (with adapter-node), Astro (SSR), or Remix? Create the app as managed. Ngris auto-detects the framework (no Dockerfile needed), runs its production build (e.g. next build), and starts its server (next start) — the edge streams the response, so React 18 streaming SSR and Server-Sent Events work out of the box. Your app is bound to the port the edge routes to automatically via the PORT environment variable.

Static export vs. SSR. A fully static export — Next.js output: "export", nuxt generate, or a plain Vite/CRA/Astro-static build — is a Static App (type static), served straight from the edge with no running process. A server-rendered app must be managed; deploying it as static fails the build with a clear “deploy as a Managed app” message.

Runtime settings

Tune how the backend runs from the app's Serving settings (Settings tab) (or PUT /v1/applications/{uuid}/runtime):

  • Port (port) — the port your server listens on (1–65535).
  • Health check path (health_check_path) — an absolute path probed for readiness (e.g. /healthz). Default /.
  • CPU & memory (cpu_millis, memory_mb) — any size up to what your plan allows (at most 4000 millicores and 8192 MiB per app; WordPress needs at least 256 MiB). The Settings tab shows the free range.
  • Replicas (min_replicas 0–10, max_replicas 1–10).
  • Backend paths (backend_paths, fullstack) — the URL prefixes routed to the backend (default ["/api"]); everything else serves the static front end.

Read live runtime status — instances, ready replicas, restarts — from GET /v1/applications/{uuid}/status.

Install, build and start commands

Ngris detects how to install, build and start your backend. To use your own commands, set them in the app's Build settings: a managed app takes an install, a build and a start command; a full-stack app takes the start command for its backend, and the install, build and output directory for its front end.

Environment variables at runtime

A backend's environment variables are delivered to the running container, not just the build — so config, tokens, and connection strings reach your process at start-up. Mark sensitive values as Secret: they're encrypted at rest, masked in the API and dashboard (write-only), and decrypted only into the running backend. Changes take effect on the next deploy or rollout.

Cron Jobs

A Cron Job runs a scheduled HTTP request against your deployed app — like Vercel Cron. Point it at a path in your app and Ngris calls it on your schedule: nightly batch work, cache warming, digest emails, cleanup tasks, anything you'd otherwise wire up a server-side scheduler for. There's no separate worker to run; the platform makes the request and your app handles it.

Add one from the app's Cron Jobs tab in the dashboard, from the API, or with the ngris apps CLI. Cron Jobs are a paid-plan feature with a per-account limit.

Needs a live serving endpoint. A cron job fires an HTTP request at your app, so the app must be deployed and reachable — a live static, managed, fullstack, WordPress, or serverless-function deploy behind a serving endpoint. The request goes to the app's primary endpoint. If the app has no served endpoint, there's nowhere for the request to land: nothing is called, and the run records why.

Schedule

A schedule is a 5-field UTC cron expression (minute hour day-of-month month day-of-week) or a descriptor like @hourly or @daily. Times are always UTC.

Schedule Runs
0 3 * * * Every day at 03:00 UTC.
*/15 * * * * Every 15 minutes.
@hourly At the top of every hour (same as 0 * * * *).

Method & path

Each job has an HTTP method (default GET) and a path (default /). On every tick the platform fires <method> https://<your-app><path> — so a job with path /cron/digest requests https://your-app.ngris.dev/cron/digest on schedule. Point it at a route your app handles.

Verify the request with CRON_SECRET

Your cron path is reachable over the public internet, so verify that a request really came from the platform before doing any work. Set a CRON_SECRET environment variable on the app, and every cron request carries it as an Authorization: Bearer <CRON_SECRET> header. Compare it in your handler and reject anything that doesn't match:

// Node — verify the request is a genuine platform cron
export default function handler(req, res) {
  const auth = req.headers["authorization"];
  if (auth !== `Bearer ${process.env.CRON_SECRET}`) {
    return res.status(401).send("unauthorized");
  }
  // …run the job…
  res.status(200).send("ok");
}

Mark CRON_SECRET as a Secret so it's encrypted at rest and never returned by the API or dashboard.

Run now

Hit Run now on a job (or ngris apps cron run <job> --app <uuid>, or POST /v1/applications/{uuid}/cron-jobs/{cron_uuid}/run) to fire it immediately, off-schedule — the exact request the scheduler would send, Authorization header and all. Use it to test a new job end to end without waiting for the next tick.

Manage jobs

Create, list, update, and delete jobs from the Cron Jobs tab, the ngris apps CLI, or the API: GET / POST /v1/applications/{uuid}/cron-jobs, PUT / DELETE /v1/applications/{uuid}/cron-jobs/{cron_uuid}, and POST /v1/applications/{uuid}/cron-jobs/{cron_uuid}/run. See the Cron Jobs API reference for the full schema.

From the CLI
ngris apps cron --app <uuid>                                   # list: state, schedule, next run (UTC), last status
ngris apps cron add --schedule "0 * * * *" --path /api/cron --app <uuid>
ngris apps cron run <job> --app <uuid>                          # fire now (exit 1 unless 2xx/3xx)
ngris apps cron pause|resume|rm <job> --app <uuid>              # <job> = UUID, unique prefix, or name

Web Analytics

Web Analytics gives you privacy-friendly, cookie-free pageview analytics for an app — pageviews, unique visitors, top pages, countries, and devices, on the Analytics tab of the app's detail page. It is a read-only view over the requests your app's endpoints log at the edge: there's no tracking script to add, no cookie, and no per-visitor identity. Unique visitors are counted as distinct source IP addresses.

Nothing to turn on per app. The Analytics tab lights up as soon as your plan includes Web Analytics (the web_analytics entitlement). There's no per-app switch and no script to install. An endpoint with request capture turned off logs no requests, so it adds nothing to the figures.

What counts as a pageview

A pageview is a successful (2xx) GET of a page. These are left out:

  • Bots: crawlers, headless browsers and link previews. They aren't visitors; the device breakdown shows how many there were as Bot.
  • Assets: scripts, stylesheets, images, fonts and other files. .html pages count.
  • Redirects, 304 Not Modified answers and errors.
  • Backend traffic. A full-stack app counts its front-end pages, not its backend paths (/api by default). A managed app counts the page GETs outside its backend paths. A serverless function is an API, so it shows no web analytics.

What you get

  • Pageviews and unique visitors (distinct source IPs) for the selected range.
  • Top pages, top countries, and a device breakdown (desktop / mobile / tablet).
  • A time series of pageviews for the chart (hourly for 24 hours, daily otherwise).

From the API

Fetch the same aggregates with GET /v1/applications/{uuid}/analytics. Choose the window with range=24h|7d|30d|90d (default 7d):

curl "https://api.ngris.com/v1/applications/<uuid>/analytics?range=7d" \
  -H "X-API-KEY: <your_api_key>"

See the Web Analytics API reference for the full response schema.

Speed Insights

Speed Insights reports your app's Core Web Vitals from real visitors (RUM) — the p75 of LCP, CLS, INP, FCP, and TTFB, each with a good / needs-improvement / poor rating and a trend chart, on the Speed Insights tab. Measurements come from real browsers — there are no cookies and no identifiers. It's a paid-plan feature (the speed_insights entitlement).

Turning it on

Enable Speed Insights per app with the speed_insights_enabled setting — from the app's settings, or by sending speed_insights_enabled: true to PUT /v1/applications/{uuid}. Enabling requires the speed_insights entitlement (a 403 without it); disabling is always allowed.

The collector is auto-injected. Once enabled, the edge serves a tiny collector at /_ngris/speed-insights/script.js on your app's own domain and receives the browser beacon at /_ngris/vitals. For static apps and fullstack static front-ends the edge auto-injects the collector into your served HTML — there's nothing to add by hand.

Backend-rendered HTML

For pages rendered by a dynamic managed backend, the edge doesn't rewrite the response body, so add the collector yourself — one line before </body> in your template:

<script src="/_ngris/speed-insights/script.js" defer></script>

From the API

Read the metrics with GET /v1/applications/{uuid}/speed-insights (range=24h|7d|30d|90d, default 24h). The response gives total_samples, per-metric p75 + rating + good/needs-improvement/poor counts, and a time series:

curl "https://api.ngris.com/v1/applications/<uuid>/speed-insights?range=7d" \
  -H "X-API-KEY: <your_api_key>"

See the Speed Insights API reference for the full schema.

Skew Protection

Skew Protection keeps a redeploy from breaking visitors who are already on the old version of your page. When you ship a new build, content-hashed asset filenames change — and a browser still on the previous page can then request a JavaScript or CSS chunk that only the old deploy shipped, and 404 (a broken page or blank screen). With Skew Protection on, Ngris serves that asset from the deploy the visitor's page actually came from. It's a paid-plan feature (the skew_protection entitlement).

Turning it on

Enable Skew Protection per app with the skew_protection_enabled setting — from the app's settings, or by sending skew_protection_enabled: true to PUT /v1/applications/{uuid}. Enabling requires the skew_protection entitlement (a 403 without it); disabling is always allowed.

How the pin works. When the edge serves an HTML document it sets a 24-hour ngris_dpl cookie pinning the current deploy. On that page's later asset requests, if a chunk is missing from the (now newer) current deploy, the edge retries it against the pinned deploy. The pin only ever resolves a deploy that belongs to this app, and there's no per-request database write.

When to use it

It's most useful for static, SPA, and fullstack static front-ends built with content-hashed asset filenames (Vite, webpack, Next.js static export, and similar), where an in-place redeploy would otherwise 404 stale chunks for in-flight visitors. Because the pin follows deploy retention, a visitor is protected for up to 24 hours or until the old deploy's artifacts are pruned — whichever comes first — after which their next request simply loads the current deploy.

See the Skew Protection API reference for the request and response schema.

Edge caching & ISR

A managed backend's own responses are cached at the Ngris edge automatically — but only when the app opts in by sending a shared-cache Cache-Control header: s-maxage=<sec> or public, max-age=<sec>. This is how Next.js/Nuxt ISR (the revalidate option) and any cache-controlled API get served straight from the edge without waking the container. There is no configuration — it's driven entirely by the header your app returns.

Only safe responses are ever stored, so a cache hit can never leak private data:

  • The request is a GET or HEAD and the response is a 200.
  • The response carries a shared-cache Cache-Control and no Set-Cookie.
  • The request carries no Authorization or Cookie header, and the response has no unsafe Vary.

A cache hit is visible as an X-Cache: HIT response header. Streaming SSR/SSE — responses with no Content-Length — is never cached and always streams live. To force a refresh, redeploy.

Opt-in, by your app. If your backend never sends a shared-cache Cache-Control, nothing is cached and every request reaches the container — the safe default. Turn caching on per route by returning Cache-Control: s-maxage=60 (or your framework's ISR revalidate) only where the response is safe to share.

Databases

An app with a backend — a managed, full-stack or function app — can use a managed MariaDB database. Databases belong to your account: create one under Storage → Databases, then connect it from the app's Environment tab (Connected databases → Connect database). ngris gives the app DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD and DATABASE_URL (or the same names behind a prefix, such as ORDERS_DATABASE_URL) and restarts it if it's running. A static site has no server, so it can't use a database.

Databases are on the Team and Enterprise plans. Users, prefixes, limits and what happens on delete are on the Databases page.

Preview deploys

A built deploy (ready or superseded) can have its own shareable preview URL, independent of which version is live, so you can review a build before promoting it or share a work-in-progress. Previews are opt-in: choose Request preview URL in the deploy's menu on the Deploys tab (or POST /v1/applications/{uuid}/deploys/{deploy_uuid}/preview), and Revoke preview URL to turn it off. A git-connected static or full-stack app with Branch previews on gets one for every push to another branch.

Find it in the Preview URL column next to the deploy, or read preview_url from GET /v1/applications/{uuid}/deploys. The URL embeds the deploy’s unguessable id, so it is shareable without exposing your other versions. Who can open it is set by the app's preview visibility (private to your account's members by default). To review a build before it goes live, deploy it as a candidate and promote it when it checks out — see Deploy a preview, then promote.

Protections on preview URLs

A preview URL follows the protections of the app's main endpoint (its primary): sign-in, IP and geo rules, the firewall, rate limits, WAF, bot and traffic rules, HTTPS enforcement and edge functions. Set them once on the main endpoint and they cover its previews too.

  • Not cached. Previews are never served from or stored in the edge cache, so a preview never shows a cached production page, and production never shows a preview's.
  • Shared rate limits. Preview requests count toward the main endpoint's rate limits and abuse blocks.
  • Client certificates. If the main endpoint requires client certificates (mTLS required), its previews answer 403: a preview address can't ask for a certificate.
  • Sign-in is per address. Signing in on the main endpoint doesn't sign you in on a preview URL, and the other way round. OAuth sign-in (Google, GitHub, OIDC) may not work on a preview URL, because your identity provider only accepts the redirect addresses you registered for the main endpoint.
  • Paused or suspended. When the main endpoint is paused or suspended, its previews are refused too.
  • No endpoint yet. An app without an endpoint has no protections to apply: its previews are gated only by preview visibility. Add an endpoint and protect it before you make previews public.

Right after you add an endpoint or change the primary, a preview can answer 503 for a few seconds until the new main endpoint is known; reload it.

Preview traffic and usage

Requests to preview URLs count toward your account’s usage (requests and data transfer), with one default exception: on the Team and Enterprise plans, requests to private previews don’t count. Public previews count on every plan, because anyone with the link can open them. See Visibility for how to make previews private or public.

Whether a request counts is decided when it’s recorded, so changing an app’s preview visibility affects new requests only.

Deploy a preview, then promote

By default every deploy goes live the moment it builds — upload or push, and the new version is serving. When you'd rather see a build first, you can deploy it as a candidate: it builds and gets its own preview URL, but production stays on the current version until you promote it.

Deploy a candidate by ticking Deploy as preview — don't make it live yet on the Deploys tab, or by sending activate=false to the API — on the upload deploy, redeploy, or quick-deploy call. The deploy comes back with auto_activate: false so you can tell a candidate apart from a live build.

curl -X POST "https://api.ngris.com/v1/applications/{uuid}/deploys" \
  -H "X-API-KEY: <your_api_key>" \
  -F "file=@dist.zip" \
  -F "activate=false"   # build a candidate; don't go live

When the candidate checks out, promote it from the Deploys tab — a newer candidate shows a Promote to production action, and promoting is the same switch as a rollback (both call activate). Managed backends re-roll the pod onto that version's image instead.

Stage every deploy by default

The activate=false flag stages one deploy at a time. To make every deploy for an app stage-then-promote — git production-branch pushes included — turn on require manual promotion on the app. With it on, each new build lands as a not-live candidate with its own preview URL, and production only moves when you promote by hand. Toggle it from the app's settings or by sending require_manual_promotion: true to PUT /v1/applications/{uuid}; leave it off (the default) to keep deploys going live as soon as they build.

Git production-branch pushes go live unless you say otherwise. Deploying a single candidate with activate=false is an upload / API feature — a push to a git app's connected production branch still auto-activates. To stage git pushes too, turn on require_manual_promotion for the app (above), which builds every deploy as a candidate you promote by hand.
“Live” needs a production endpoint. Promote, roll back, and “live” only mean something once the app has at least one endpoint attached. Every endpoint of the app serves its production deploy, so a promote or a rollback switches all of them at once. An app with deploys but no endpoint yet has nothing live: its deploys are just builds with preview URLs. Attach a domain / endpoint to give it a production URL.

Visibility — public or private

Visibility controls which Ngris users can reach your app at all. You set it independently for the production URL and for preview URLs, so you can ship a public site while keeping every preview build inside your team — or lock the whole app down while it's in progress.

Value Who can view
public Anyone with the link — served exactly as before, no sign-in.
private Only members of the app's owning Ngris account.

Defaults: production is public and previews are private — your live site is open to the world, while a fresh preview build is visible only to your team until you choose to share it. Change either from the app's Visibility settings or with PUT /v1/applications/{uuid}/visibility. Visibility is available on every app — there is nothing to enable and no plan gate.

How a private app is enforced

Enforcement happens at the edge, on every request — there is nothing to wire into your app. When an app is private, a visitor must (1) be signed in to Ngris and (2) be a member of the app's owning account:

  • A visitor who isn't signed in is bounced to the Ngris sign-in page, then returned to the app.
  • A signed-in user who is not a member of the owning account gets a branded “You don't have access” page.
  • A public app is served straight through, exactly as before.

This applies both to apps on Ngris platform domains (for example *.ngris.dev) and to your own custom domains (BYOD / CNAME). Preview URLs (<deploy-uuid>.<domain>) honour preview_visibility.

Visibility is not Endpoint Auth. Visibility decides which Ngris users can reach the app — it's account-level gating, run by Ngris. Endpoint Auth is different: that's where you run your own login / registration for your end-users (clients, OAuth providers, RBAC). The two are independent and can be used together — a public app can still require Endpoint Auth for its end-users, and a private app restricts who from your account can even see it.

Domains, endpoints & HTTPS

An app is exposed by agentless endpoints, and it can have several — for example example.com and www.example.com, or a free name.ngris.dev address next to your own domain. Every endpoint serves the app's production deploy; preview URLs stay separate. On a shared domain an endpoint gets an auto-generated name.ngris.dev URL instantly; to use your own domain, add it under Domains, then create the endpoint on it — TLS is provisioned automatically.

Add an endpoint from the app's Domains tab (Add endpoint) or with the API. It is the same call for the app's first endpoint and every other one:

curl -X POST "https://api.ngris.com/v1/endpoints" \
  -H "X-API-KEY: <your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{"serving_mode":"agentless","application_uuid":"<app_uuid>","subdomain":"app","is_custom":true,"domain_uuid":"<domain_uuid>"}'

The Domains tab lists every endpoint of the app with its TLS certificate, status, health check and sign-in state; the Overview tab's Uptime card shows each endpoint's uptime. From the API, GET /v1/applications/{uuid} returns the same list in endpoints, and GET /v1/endpoints?application_uuid=<app_uuid> filters the endpoint list to one app.

Endpoint limit

Every endpoint counts against your account's endpoint quota, the same one your tunnels use. The Domains tab shows how much of it is used; the limit depends on your plan (GET /v1/account/plan returns it as plan_limits.max_endpoints). At the limit, creating another endpoint is refused with 403 endpoint limit reached (N): remove an endpoint, or upgrade your plan.

The primary endpoint

One endpoint is the app's primary, marked Primary on the Domains tab. Its hostname is the host your cron jobs call, and a WordPress app's site address. The app's first endpoint becomes the primary. To change it, choose Make primary in the endpoint's menu on the Domains tab, or call PUT /v1/applications/{uuid}/primary-endpoint:

curl -X PUT "https://api.ngris.com/v1/applications/{uuid}/primary-endpoint" \
  -H "X-API-KEY: <your_api_key>" \
  -H "Content-Type: application/json" \
  -d '{"endpoint_uuid":"<endpoint_uuid>"}'

The primary must be served: an endpoint paused over your plan's limits, on a custom domain paused over your plan's limits, or suspended by the ngris team can't be made primary. If you remove the primary endpoint, the app's oldest remaining served endpoint becomes the primary.

Settings are per endpoint

Everything you can put on a normal endpoint applies here too — auth, firewall, rate limits, caching and its TLS certificate — and each endpoint has its own. Open Manage settings in an endpoint's menu to change them. The app's visibility is set once per app and applies to every endpoint.

A new endpoint starts without sign-in. Adding an endpoint doesn't copy another endpoint's settings, so it has no sign-in even when the app's other endpoints require one (the app's visibility still applies). Check the Sign-in column on the Domains tab. With two or more endpoints, Apply sign-in to all sets one sign-in mode on every endpoint of the app (a paused endpoint is skipped): no sign-in, sign-in with a password or client token, or the same with SSO offered. Each endpoint keeps its own users, client tokens and OAuth providers — set those up in its settings, or nobody can sign in. From the API, set one endpoint with PUT /v1/endpoints/{uuid}/settings, or one mode on many with POST /v1/endpoints/bulk-settings.

WordPress: one site address

WordPress has a single site address: the app's primary endpoint. Visitors who open the site on any of its other endpoints are redirected to the primary, and the Domains tab marks those rows Redirects to the primary. That is how a www and apex pair works: make the address you want visitors to land on the primary. When the primary changes, the site address follows.

A new WordPress site has no endpoint: until you add one, its address is its preview URL, private by default. Adding an endpoint, removing one, or changing the primary restarts the site so it picks up the new address — expect about a minute of downtime.

Deleting an app

Deleting an app never deletes its endpoints, and the delete is refused (409) while any endpoint still serves the app. Remove each endpoint first (Remove endpoint in its menu), then delete the app.

Deploy lifecycle — restart, roll out & roll back

Every upload or push creates a new immutable deploy, and by default each one goes live as soon as it builds. The app has one live deploy at a time. The first successful build activates itself (unless you deployed it as a candidate); from then on you switch versions explicitly from the Deploys tab (or POST /v1/applications/{uuid}/deploys/{deploy_uuid}/activate).

Activating a deploy from the Deploys tab covers three things:

  • Roll back — activate an older, still-rollbackable version. Static apps flip to its built bundle instantly; managed backends re-roll the pod onto that version's digest-pinned image.
  • Restart / roll out — activate the current version to re-ship it (managed backends re-roll the running pod; useful to pick up new env vars).
  • Promote to production — make a reviewed candidate (a newer build deployed with activate=false) live.

For a static app this is an atomic pointer flip — no rebuild, no downtime. For a managed backend the rollout goes through the runtime: it re-points the Deployment at that deploy's already-built, scanned, signed image and advances the live version only once the new pods are proven ready.

Rollback window (retention)

Only the newest N terminal deploys stay rollbackable (the deploy_max_rollback_versions plan limit — more on higher tiers). Older deploys are pruned: their build artifacts (the static bundle, or the backend's container image) are deleted to bound storage, and they can no longer be rolled back to. The row survives for history, so the Deploys tab still lists a pruned version — just with no Roll back action. The currently-live version is always kept, never pruned.

In the API, each deploy carries rollbackable and pruned flags so you can tell which versions can still be activated. Trying to activate a pruned deploy returns 409 — re-deploy from source instead.

Was this helpful?