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.
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.
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.
Latest deploy
LiveLive URL
ServingQuick 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.
New Static site
We'll build and deploy from here.
.zip of your built site. Re-upload anytime for a new version.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.htmlsits 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).
Deploy a new version
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>.
New Static site
A few details and you're live.
Push to deploy
Auto-deploy onAdd this webhook to your GitHub repo so every push to main rebuilds and publishes.
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 ifindex.htmlis at the archive root. When Ngris builds your site, leave it blank and set the Output directory instead. Don't set both todist: your site would then be looked for indist/dist. - Index document (
index_document) — served for directory requests. Defaultindex.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.
Serving settings
dist when you upload a site that is already built. When ngris builds your site, leave it at / and set the build's output folder under Build settings.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.
Build settings
Leave a box empty and ngris detects it. Builds run on Node.js 24. Saving doesn't rebuild: the next deploy or Redeploy uses these settings.
npm ci --legacy-peer-deps.npm run build. One line: chain commands with &&.dist. Everything in it is published.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_URLset 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.
NGRIS_APP_ENV_*. Each variable can target Production, Preview deploys, or Both. Secrets are encrypted at rest and never shown again — to change one, enter a new value. Saving a change applies it right away: a running managed app rolls to pick it up.
Add variable
| Name | Value | Type | |
|---|---|---|---|
| VITE_API_URL | https://api.example.com | plain | |
| SENTRY_AUTH_TOKEN | •••••••• | secret |
Build log
v3Build finished. This log is complete.
Prepare context
Build
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.
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.
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_replicas0–10,max_replicas1–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.
Serving settings
/ to route everything to the backend./healthz).Runtime health
ReadyCron 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.
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.
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
Cron Jobs
Run a scheduled HTTP request against your app's live URL — like Vercel Cron. The schedule is a 5-field cron expression in UTC (or @hourly/@daily). If you set a CRON_SECRET environment variable, each request carries Authorization: Bearer <CRON_SECRET> so your handler can verify it. Requires a live serving endpoint.
| Schedule (UTC) | Target | Status | |
|---|---|---|---|
| 0 3 * * * | GET /cron/digest | Enabled HTTP 200 | |
| */15 * * * * | GET / | Paused |
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.
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.
.htmlpages count. - Redirects,
304 Not Modifiedanswers and errors. - Backend traffic. A full-stack app counts its front-end pages, not its backend paths (
/apiby default). A managed app counts the pageGETs 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.
Top pages
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.
/_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.
Serving settings
</body>:<script src="/_ngris/speed-insights/script.js" defer></script>
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.
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.
Serving settings
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
GETorHEADand the response is a200. - The response carries a shared-cache
Cache-Controland noSet-Cookie. - The request carries no
AuthorizationorCookieheader, and the response has no unsafeVary.
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.
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.
Connected databases
Connect database| Database | Connects as | Variables | Used in | |
|---|---|---|---|---|
| orders | web | DB_* |
Both |
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 answer403: 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.
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.
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
publicapp 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.
public app can still require Endpoint Auth for its end-users, and a private app restricts who from your account can even see it.
Visibility
Control who of our ngris users can reach this app. Public — anyone with the link can visit. Private — only your team (account members) can visit; they sign in with ngris first. A private project stays visible only to your team until you make it public.
Anyone with the link can visit.
Only your team (account members) can visit — they sign in with ngris.
This sets who can reach the app; it's separate from an endpoint's own auth policies.
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.
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.
Every endpoint serves this app's production deploy. Each one has its own hostname, TLS certificate and traffic settings. Previews keep their own URLs.
| Hostname | TLS | Sign-in | |
|---|---|---|---|
|
example.com
Primary
|
Valid | Sign-in required | |
| Issuing… | No sign-in |
| Edge endpoint |
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.