Documentation Updated September 29, 2026

Everything you need to build, deploy and run your apps and data services on JetDeploy, from your AI agent, the Console or the API.

Getting started

JetDeploy runs your code and your databases on a managed Kubernetes cluster, without asking you to learn Kubernetes. You push your code with git, and JetDeploy builds, deploys and keeps it running.

There are three ways to work with it, on the same account and the same resources:

Concepts

  • Organization: owns every app, service, domain and operation, and is billed as a whole for a reserved amount of vCPU and memory they all share (see Billing).
  • App: a git repository that JetDeploy builds into a container image and runs.
  • Process: one container started from the App image, such as a web server or a worker. An App runs one or more; the API calls them pods.
  • Data service: a managed PostgreSQL, MariaDB, Redis, OpenSearch or RabbitMQ running next to your Apps.
  • Domain: a hostname of yours, validated and attached to an App.
  • Operation: a deploy, apply, restart, start, stop, expose, unexpose or destroy. It runs in the background and ends in SUCCESS, FAILURE or REVOKED.

First deploy from the Console

  1. Sign in to the Console and create an App, choosing the branch you want to deploy.
  2. Add the git remote the Console shows you and push that branch.
  3. Describe the process to run: its command, its listening port, whether it faces the internet.
  4. Click Deploy on the App page: the push above happened before the App had a process to run it, so it was only recorded, not built.
  5. Open the public URL of your App. It is ready as soon as the process accepts connections.

Every App gets a host under jetdeploy.app with HTTPS out of the box. You can attach your own domains later, and add data services such as PostgreSQL or Redis whenever your App needs them.

Apps

An App is a git repository that JetDeploy builds into a container image and runs for you. Anything that runs in a container works: Python, Node.js, PHP, Go, Ruby, Java or a plain Dockerfile.

Its name becomes the App's default host, <name>.jetdeploy.app. Names are unique across all of JetDeploy, not just your organization: see Names.

Build

The build uses the Dockerfile at the root of your repository. If your project has none, add one: a few lines are usually enough.

FROM python:3.13-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:application"]
  • The build has 1200 seconds.
  • It does not see the environment variables of the App, so a step there has to run without them. For Django, give the settings a value to build with, e.g. RUN SECRET_KEY=build-only python manage.py collectstatic --noinput.
  • A step that produces files, such as compiling assets or collecting static files, belongs in the build, not in the pre-deploy script, whose files never reach your processes.

Deploy with git push

Each App has a private git remote on JetDeploy. Add it to your repository and push the branch you chose when creating the App (main by default):

git remote add jetdeploy https://git:<token>@jetdeploy.com/git/<app>
git push jetdeploy main

<token> is your Git Access Token, shown on the App page and on the Git Access Token page under Integrations.

The token works on every App of every organization you belong to: never commit it, and regenerate it from the Git Access Token page if it leaks.

What a push does:

  • Once the App has at least one process, every push to that branch triggers a new build and a rolling deploy: the new version starts, passes its readiness check, and only then replaces the old one.
  • Before the App has a process, a push is only recorded: no build starts, since there is nothing yet to run it in. Add a process, then click Deploy on the App page (or call POST /api/v1/apps/{app_id}/deploy).
  • Pushing again deploys only when the push carries a new commit, since git sends nothing for a branch that is already up to date.
  • Pushes to other branches are ignored.

Keeping the token out of your git config

Use https://jetdeploy.com/git/<app> as the remote and, when git asks, answer git as the username and paste the token as the password.

Your operating system remembers it: on macOS and Windows out of the box, on Linux after git config --global credential.helper store.

From an agent or a CI job

An agent or a CI job should not store the token at all: pass it through a one-shot credential helper from an exported environment variable (export JD_GIT_TOKEN=<token>).

git -c credential.helper= -c credential.helper='!f() { echo username=git; echo "password=$JD_GIT_TOKEN"; }; f' \
  push https://jetdeploy.com/git/<app> HEAD:refs/heads/<branch>

The empty -c credential.helper= resets the helper list first. Without it git appends yours to whatever is already configured, such as the store one above: a stale stored token is then tried first and fails the push, and a successful push saves your token to disk.

In GitHub Actions, add the Git Access Token as the JD_GIT_TOKEN secret of the repository. fetch-depth: 0 is required: a push from a shallow clone is refused.

name: Deploy to JetDeploy
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - run: |
          git -c credential.helper= -c credential.helper='!f() { echo username=git; echo "password=$JD_GIT_TOKEN"; }; f' \
            push https://jetdeploy.com/git/<app> HEAD:refs/heads/main
        env:
          JD_GIT_TOKEN: ${{ secrets.JD_GIT_TOKEN }}

An agent working from the API token alone reads the Git Access Token from GET /api/v1/profile, field git_token.secret; see Your first deploy, call by call.

Processes

A process is one container started from the App image. An App usually has one external-facing web process, and can add more: a worker, a scheduler, a queue consumer. Each process has:

  • Name: unique within the App, lowercase letters, digits and hyphens. See Names for the other rules.
  • Command: optional, overrides the CMD of your Dockerfile. A single command receives the shutdown signal directly; end a shell script with exec <process> so that it does too.
  • Container port: the port your process listens on. The process must listen on 0.0.0.0 on this port, not on 127.0.0.1: JetDeploy waits for it to accept connections before sending traffic.
  • External-facing: whether the process receives HTTP traffic from the internet on the App host and on the attached domains.
  • Replicas: how many copies of the process run, 1 to 5. A change takes effect at the next Apply, deploy or start; a stopped process stays stopped and starts with the new count.
  • Warmup delay: extra seconds to wait before the process is marked ready, on top of the port check.
  • Shutdown grace period: seconds your process gets to finish after SIGTERM before it is killed, 1 to 900.

JetDeploy sets no PORT variable. An app that reads PORT from its environment needs it set in the environment variables, to the same value as the container port.

Kubernetes also sets variables named after the processes and data services of the organization, such as REDIS_PORT=tcp://<ip>:6379 for a service named redis. Set the variables your app reads explicitly, rather than relying on a fallback.

Other Apps and services of your organization reach a process on its internal address, http://<app>-<process>:<port>, which never goes through the edge.

Start, stop and restart

Processes can be started, stopped and restarted one by one from the App page, or all at once from the App.

  • A restart is a rolling restart, like a deploy: a new pod starts and passes its readiness check before the old one stops, so it causes no downtime.
  • A start or restart operation ends once the new pods are ready. On a restart, the old pod of a process with a port then gets no new requests and 5 more seconds to finish the ones in flight before SIGTERM, in the background.
  • Stopping the whole App stops every process and keeps your data services running.

If the new pods of a start or restart never become ready, the operation ends in FAILURE. When the cause is in the app (the process exits, runs out of memory or nothing listens on its port), its error_message names the process and the cause and points to its runtime logs; otherwise the message is generic, and the rollout goes on by itself.

A stopped App or process stays stopped across every Apply and deploy, a push included, until you start it again: the deploy rolls the new image out to it without starting it.

Starting the App starts every process, the ones stopped one by one included. Through the API, restarting a stopped App or process starts it the same way; the Console offers Start for it instead.

A process added to a stopped App is stopped too: apply the App, then start it.

Shutdown

Every deploy, restart and stop sends SIGTERM to the process and kills it after the shutdown grace period.

A process running as PID 1, such as CMD ["node", "server.js"] with no shell or init in between, must handle SIGTERM itself. Otherwise it keeps running, holding its resources, until it is killed at the end of the grace period, cutting off whatever it was doing.

Environment variables

Configuration lives in environment variables, set from the App page and visible to every process. Use them for secrets, connection strings and feature flags instead of committing them.

DATABASE_URL=postgresql://user:password@host:5432/dbname
SECRET_KEY=change-me

Changes are applied with a restart of the processes, which the Console does for you when you click Apply. To connect to a data service, see Connecting from an app.

Applying changes

A change to the processes, environment variables, domains or redirect rules of an App is stored first and goes live when you click Apply, whether the App is running or stopped.

  • Until then, whenever no operation is running on the App, the App page lists it under Unapplied changes and the list of Apps marks the App Apply pending.
  • The list can also hold a platform-driven change, such as a new image on an App already deployed.
  • A deploy brings every stored change live by itself, so an App that was never deployed has nothing to apply.

The pre-deploy script and its resource limits are never among the changes to apply: an apply does not run the script, so a change to either only takes effect at the next deploy or push.

Pre-deploy script

A pre-deploy script runs after the build and before the new version receives traffic, inside the freshly built image with the same environment variables as your processes.

It is the place for steps that change shared state, such as a database migration or a cache warmup:

python manage.py migrate --noinput
  • A script that fails is not run again. Only when the platform interrupts its container (a node drain or eviction) does it start again in a new container, so write it to be safe to run twice, the way a migration already is. Each attempt's output starts with a Pre-deploy attempt N line.
  • It runs in a container of its own, with no filesystem shared with your processes: anything it writes there never reaches them. Produce files in the build instead.
  • It has at most 510 seconds, restarts included, and the script and the start of the new version together at most 600 seconds.
  • If it fails or runs out of time, the deploy fails and the previous version, if there is one, keeps serving: see When a deploy fails.

Past the time limit the pre-deploy is stopped wherever it got to, whether the script was running or its container never started. A script stopped halfway can leave a database without transactional DDL, such as MariaDB or MySQL, half migrated.

A step that needs longer runs outside the pre-deploy script: deploy without the script, run the step once in the new version with POST /api/v1/apps/{app_id}/pods/{pod_id}/exec or the shell of the App page, then set the script again.

Until the step finishes the new version runs against the old schema, so such a migration has to be backward compatible.

When a deploy fails

A failed deploy ends its operation in FAILURE, and what happens next depends on the step that failed:

  • The build fails or runs out of time: the previous version, if there is one, keeps serving. For a failed build the error message carries the failing build phase and the last lines of the build log.
  • The pre-deploy script fails or runs out of time: the previous version, if there is one, keeps serving. For a failed script the error message carries the exit code of its last attempt and the last lines of its output.
  • The new version does not become ready in time: the rollout goes on, and the new version takes over as soon as it is ready. The error message says, for each process, why it is not ready (it exits, it runs out of memory, nothing listens on its port) and points to the runtime logs.

HTTPS and proxy headers

The JetDeploy edge terminates HTTPS and forwards the request to your process as plain HTTP.

Plain http:// requests to your App host and to your custom domains never reach your process: the edge redirects them permanently to https:// first (301 for GET, 308 for other methods).

On every request the edge replaces these headers the client sent with its own, so your process can trust them:

  • X-Forwarded-Proto: https.
  • X-Forwarded-For and X-Real-Ip: the address that connected to the edge.
  • X-Forwarded-Host and X-Forwarded-Port.

Every other header, such as Forwarded, X-Forwarded-Ssl or CF-Connecting-IP, reaches your process exactly as the client sent it.

A framework check like Django's request.is_secure() sees an insecure request unless you tell it where to look:

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

Other frameworks: trust X-Forwarded-Proto from the proxy the same way.

Leave Django's SECURE_SSL_REDIRECT off: the edge already redirects plain HTTP. Turning it on also redirects the plain HTTP requests other Apps and services of your organization make to the process on its internal address, which carry no X-Forwarded-Proto: exempt those paths, or leave the redirect to the edge.

For a domain in proxy mode the connecting address is your CDN or WAF, not the visitor: the visitor's address is in a header the CDN adds, such as CF-Connecting-IP for Cloudflare.

Trust it only when X-Real-Ip is one of your CDN's published addresses, since anyone can reach the JetDeploy origin directly and set that header themselves.

AI Crawler & Bot protection

AI Crawler & Bot protection keeps scrapers and automated traffic away from an App. Turn it on from the App page, under AI Crawler & Bot protection.

It works at the application level: it stops automated clients that cannot pass the browser check before they reach the App.

While it is on:

  • A browser opening the App gets a quick check page before the site, once every 30 days per browser and IP address.
  • Requests that do not look like a browser, such as API clients, servers, webhooks and curl, are not challenged, unless Challenge all clients is on.
  • Calls from the pages of the App to its own host carry the pass of the browser and work as before.
  • It covers the default host of the App and every custom domain attached to it; a domain added later is covered once it is validated and applied.

Other sites or apps that call the App from the visitor's browser, for example a frontend on another domain using its API, cannot show the check page. List the paths they call as excluded paths: one path prefix per line, such as /api/.

  • A request whose path starts with an excluded path is not checked at all, bots included, on every host of the App and for every method.
  • /api/ covers /api/orders but not /apiary.

Challenge all clients, under Edit protection settings on the App page, gives the check to every request, whatever it says it is. Use it for an App that only browsers should reach. With it on:

  • API clients, webhooks, curl, mobile apps, uptime monitors, feed readers and link previews no longer reach the App, except on its excluded paths.
  • The verified crawlers of the main search engines, /.well-known/, /robots.txt, /sitemap.xml and the favicon still pass.
  • Known bad bots are refused as before.

On a protected host the path /.within.website/ serves the check page, so the App does not receive requests under it.

Each organization gets its own AI Crawler & Bot protection on its own node. It uses up to 256 MiB of memory, which shows in the resource charts of the organization.

Turning it on or off, and changing the protection settings, is applied to the running App right away, with no Apply and without the other unapplied changes, so you can switch it on in the middle of a wave of automated traffic. While another operation is running on the App, wait for it to finish first.

On an App that was never deployed the setting is kept and the first deploy applies it.

A deploy or Apply of a protected App always goes ahead. When the protection is still starting, the operation ends with a notice, and the protection is added by itself within a few minutes; when it cannot be activated, the notice says so and our team is notified. When turning it on cannot complete, the operation fails with the reason and the setting stays off: turn it on again a few minutes later.

From the API, send PUT $API/apps/42/bot-protection ($AUTH and $API as in Your first deploy); the answer carries the operation that applies it:

curl -sS -X PUT -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"enabled": true, "excluded_paths": ["/api/"], "all_clients": false}' \
  $API/apps/42/bot-protection

Leave excluded_paths or all_clients out to keep what is stored. The current settings are bot_protection, bot_protection_excluded_paths and bot_protection_all_clients in GET $API/apps/42. Through MCP the same call is the set_app_bot_protection tool.

Logs, metrics and shell

The App page shows:

  • the deploy logs of every build;
  • the live runtime logs of every process;
  • CPU and memory metrics charted per process, so you can see when your App needs more resources;
  • a browser shell, a terminal inside a running process for one-off commands and debugging.

Log levels

The level shown and filtered on is the one the log store detects on each line when it arrives. To make the filter reliable, log JSON with a level field set to a word.

Otherwise put the level at the start of each log line, e.g. a Django LOGGING console handler formatted as [%(levelname)s] %(message)s.

A JSON or logfmt line is read from its level field (or severity, lvl) when that is a word such as info, warn or error. A numeric level, such as pino's 30, is not read.

Any other line, and a JSON or logfmt line without such a field, is matched on its text, and the first rule that matches wins:

  • info for info or INFO anywhere, even inside a word or a URL.
  • error for [ERROR], [error], [ERR], [err], ERR: or err: (not ERROR: or Error:).
  • warn for [WARN], [warn], WARN:, warn: or the word warning/WARNING anywhere.
  • critical for [CRITICAL], [critical], CRITICAL: or critical:, which the error filter matches.
  • debug for debug/DEBUG anywhere.
  • unknown for a line with none of these, which is what the other filter matches.

Names

The name of an App or a data service shares one name space with every App and Service on JetDeploy, not just yours. A name refused for any of these reasons answers 400; see Errors for the name_taken, name_unavailable and name_clash codes.

  • An App or Service name in use by any App or Service, or one that clashes with the derived resource names of a data service (such as <service>-master), is refused.
  • A data service name, and each of its resource names (-master, -headless, -dashboards, -management, -ingress, -env, -files, -nodeport), must not match an existing process of the organization, as <app>-<process>.
  • The name anubis, and every name starting with anubis-, is kept for the AI Crawler & Bot protection and refused for a new App or Service (code name_clash).
  • Names that would read as a JetDeploy or well-known host under jetdeploy.app, such as www, api, admin, login, mail, status, docs, google or paypal, are reserved and refused for a new App or Service (code name_clash).
  • A process name must be unique within its App (code name_taken, the hint naming the process already holding it).
  • Combined with the App name as <app>-<process>, a process name must not match another process of the organization the same way, nor a data service of the organization or one of its resource names (code name_clash).

Once the destroy operation of an App or Service ends with status SUCCESS, its name can be reused in the same organization and stays taken in every other one. Before that, a create with the same name may be refused.

If the destroy ends in FAILURE while GET on the App or Service still answers, the name is still taken: send DELETE again.

Through the API and the MCP tools a destroy names what it destroys: pass the exact name of the App or Service as confirm_name, as in DELETE /api/v1/apps/42?confirm_name=my-app. Without it, or with another name, the destroy is refused with 400 and code confirmation_required.

Data services

A data service is a managed database, cache, search engine or message broker that JetDeploy runs next to your Apps, with persistent storage and automatic restarts. Pick one from the catalog and it is ready in minutes:

  • PostgreSQL (with PostGIS 3, enable it with CREATE EXTENSION postgis;) and MariaDB for relational data
  • Redis for caching, sessions and queues
  • OpenSearch, with OpenSearch Dashboards, for search and analytics
  • RabbitMQ for messaging between processes

Each service has a detail page with logs, metrics and the same start, stop and restart controls as an App.

Unlike an App's rolling restart, restarting a service recreates its pod (for OpenSearch, the Dashboards one too), so it has a moment of downtime. A stopped service stays stopped until you start it.

Storage and memory

Each service has its own storage volume, sized when you create it and not resizable afterwards.

There is no CPU or memory size to set per service, except Redis; for the other engines, see Billing.

For Redis you choose its memory in MiB (multiples of 128, from 128 to 16384, default 256), fixed once the service is created. That memory is the cap on the dataset Redis keeps: once it is full, writes are refused with an out-of-memory error rather than evicting keys.

The container memory limit and the storage volume both follow from it, so there is nothing else to size for a Redis:

  • memory limit: three times the memory plus 256 MiB;
  • storage volume: three times the memory, rounded up to the GiB.

Connecting from an app

The detail page of a service shows what an App needs to connect:

  • its internal endpoint, <host>:<port> (for OpenSearch http://<host>:9200);
  • the password;
  • the username and the database name, where the engine has them.

JetDeploy sets none of this on your App automatically: copy the values into the environment variables of your App yourself, either as separate variables or composed into one:

DATABASE_URL=postgresql://<username>:<password>@<internal endpoint>/<database>
REDIS_URL=redis://default:<password>@<internal endpoint>

Redis credentials are only a password, authenticated as the built-in default user, and the connection uses no TLS.

A generated password is letters and digits only, so it needs no percent-encoding when it ends up in a URL. Apps and services of the same organization share a private network, so the connection never leaves the cluster and needs no TLS or firewall rules.

Exposing to the internet

By default a service is reachable only from your Apps. Click Expose on its page to open it to the internet for external tools, migrations or a BI dashboard.

You choose the list of allowed IP ranges (CIDRs); every other address is rejected at the network edge. Unexpose closes it again at any time.

An exposed PostgreSQL, MariaDB, Redis or RabbitMQ service answers on a TCP port passed through as is, at <name>.jetdeploy.app:<port>:

  • Reach it from database drivers, CLI tools and scripts. Browsers open .app names over HTTPS only, so they do not load a port serving plain HTTP.
  • Any name that resolves to the inbound IP addresses reaches the same port, so a name of your own with A records to them works too.

The web panel of a service, OpenSearch Dashboards or the RabbitMQ management UI, answers only from the allowed IP ranges of the service, whether the service is exposed or not:

  • The list is saved when an Expose starts, and kept after Unexpose. An Expose cancelled before it starts leaves the previous list in place.
  • While a service has no allowed IP ranges, its panel is not reachable from outside.

Domains

Every App answers on its default host under jetdeploy.app. To serve it on your own domain, add the domain in the Console, prove you own it, and attach it to the App. JetDeploy issues and renews the TLS certificate for you.

Validating a domain

A custom domain is not routed until it is validated. Create the records shown on the domain page for your routing mode.

That is enough for a direct domain, unless another organization holds the name or held it recently. A proxy domain, and a contested direct one, also need the TXT record the Console shows, since a proxied record hides its target:

Type    TXT
Name    _jetdeploy.www.example.com
Value   <token shown in the Console>

Then click Validate: JetDeploy queries your authoritative nameservers directly, so there is no propagation to wait for.

Keep the records in place: they are re-checked every day, and a domain that is never validated is removed after a week.

Direct or proxy routing

A validated domain can reach your App in two ways:

  • Direct: create one A record on the domain for each JetDeploy IP address shown on the domain page, for an apex and a subdomain alike. JetDeploy terminates HTTPS.
  • Proxy: keep a CDN, WAF or proxy such as Cloudflare or CloudFront in front. Configure it to forward HTTPS traffic to the JetDeploy IP addresses shown on the domain page, and point the domain at the proxy.

The records of www.example.com in direct mode:

www.example.com.    A    52.57.53.134
www.example.com.    A    3.73.8.244
www.example.com.    A    3.123.207.124

A CNAME between your own names also validates, such as www.example.com to example.com, as long as the chain ends on those A records. No name along it may be under jetdeploy.app.

A proxy that only takes a hostname as its origin can use a name of your own with A records to the same addresses.

Attach the domain to the App at any time, before or after it is validated. Click Apply before changing your DNS records, so the App is already listening when traffic arrives; the domain itself is not routed until it is both validated and applied.

Once it is, JetDeploy requests the TLS certificate in the background: the domain page shows its status, from pending to issued, usually within a couple of minutes.

Switching the routing mode of a validated domain makes it unvalidated, since the records of one mode do not prove the other. Create the records of the new mode, validate it again and apply the App again, or it is removed after 7 days.

Redirect rules

A redirect rule sends every request for one host to another, keeping the path. The usual case is example.com to www.example.com or the other way around.

Add rules from the App page; both hosts must be domains attached to the App.

MCP

The API is also served as an MCP server at https://jetdeploy.com/mcp, so an agent can call it as tools instead of writing HTTP requests.

New to JetDeploy? Deploy with your agent walks through the setup, commands first: the connector in Claude, Claude Code, ChatGPT, Codex, GitHub Copilot and Cursor, the push from a terminal or from GitHub Actions, and what to expect from the first prompt.

Connect your agent

  • Claude: in claude.ai open Settings > Connectors > Add custom connector and give https://jetdeploy.com/mcp as the URL. Claude sends you to the JetDeploy login and asks you to allow the connection, so there is no token to copy.
  • Claude Code: claude mcp add --transport http jetdeploy https://jetdeploy.com/mcp, then /mcp to sign in.
  • ChatGPT: with Settings > Plugins > Developer mode on, open chatgpt.com/plugins, press +, give https://jetdeploy.com/mcp as the Server URL with OAuth authentication and press Create. ChatGPT sends you to the JetDeploy login and asks you to allow the connection the same way.
  • Codex: codex mcp add jetdeploy --url https://jetdeploy.com/mcp opens the JetDeploy login by itself; codex mcp login jetdeploy signs in again later.
  • GitHub Copilot in VS Code: MCP: Add Server from the command palette, or the entry {"servers": {"jetdeploy": {"type": "http", "url": "https://jetdeploy.com/mcp"}}} in .vscode/mcp.json; the login opens the first time the server is used. Install in VS Code adds that entry with one click.
  • Cursor: the entry {"mcpServers": {"jetdeploy": {"url": "https://jetdeploy.com/mcp"}}} in .cursor/mcp.json, then sign in when Cursor asks. Add to Cursor adds it with one click.

Every agent you authorize this way shows up on your AI Agents & MCP page, with the scopes it was given and when it was last used, and one click revokes it.

The same list is GET /api/v1/profile/connections and the list_connections tool. Revoking one is deliberately not a tool, so no agent revokes a connection of yours: use the AI Agents & MCP page or DELETE /api/v1/profile/connections/<id>. The agent stops at once, its git credential included.

The approval page lets you choose what the agent gets:

  • read, always granted: it reads your organizations, apps, services, domains, logs and metrics, with every secret null: environment variable values, the password in service credentials and git_remote_with_token.
  • write: it creates, changes, deploys and deletes apps, services and domains, including the code and commands they run, reads those secrets and pushes code with a git credential of its own.
  • exec, offered together with write only: it also runs commands inside running pods with exec_in_pod.

A call beyond what was granted is refused with 403 and code insufficient_scope; its hint says to connect the agent again and allow the missing permission.

Tools

There is a tool for every API operation, and it does exactly what the call does, with the same permissions, the same errors and the same rate limits. Apps, processes, environment variables, data services, domains, operations, logs, metrics and one-off commands are all there.

The exceptions are:

  • the live streams;
  • the payment methods;
  • the token regenerations;
  • the log histogram and context parts;
  • the selection of the current organization: tools take the organization argument instead;
  • the apply of a data service, reserved to the JetDeploy staff.

The one difference is in the log pages: through MCP their rows come without key, which only a client that also follows the log tails needs, and the tails are not tools.

Every reference an MCP answer makes to another call names the tool to use, such as get_operation, rather than the REST endpoint this page describes. The transport is plain HTTP: one POST per message, no websocket.

One step of a deploy stays outside the tools, the same one that stays outside the API: pushing the code. The create_app and get_app tools answer with git_remote_with_token, the git remote of the app with a git credential of that connection already in it.

An agent with a terminal can git push <url> <branch> straight away; from then on the push itself starts the deploy, as it does for you.

That credential belongs to the connection, not to you: it pushes to the apps of every organization you belong to, the pushes are yours, and it stops working the moment the connection is revoked. Only a connection given the write scope gets one; for a read-only connection git_remote_with_token is null. Your Git Access Token is never handed to a connection: the secret of git_token in get_profile is always null through MCP.

Large answers

A log page, or a metrics or resource series, whose full answer would not fit in a message an MCP client can safely accept comes back shorter instead, with a note field saying so:

  • A log page keeps fewer rows than the limit asked for, with next_cursor already continuing it from exactly there.
  • A single line bigger than that size on its own comes back cut, with a marker where it was cut, and paging carries on past it.
  • A series is sampled at a wider step.

A read whose answer is still too large is refused, naming an argument to narrow it when the tool has one. A call that already changed something answers success regardless, naming a tool to read the result when one exists.

A one-off command whose output is over that size keeps its exit code and is marked truncated, the same way a longer run is.

API

Everything the Console does, the JSON API does too: apps, processes, environment variables, data services, domains, operations, logs, metrics and billing. It is meant for scripts, CI pipelines and AI agents.

It is plain HTTP with no websocket: what the Console shows live, the API streams line by line in the body of a normal request.

The schema and the interactive docs are public and need no token: point your client, your code generator or your agent at the schema and it knows every path, every field and every error this page describes.

Authentication

Two different tokens, each on its own page under Integrations in the Console:

  • the API token, on the API Access Token page, sent as Authorization: Bearer <token> on every call under /api/v1/;
  • the Git Access Token, on the Git Access Token page, the password of the git remote used by git push. It never goes in the Authorization header.
curl -sS -H "Authorization: Bearer $JD_TOKEN" https://jetdeploy.com/api/v1/me

A missing or wrong token is answered with 401 and code unauthorized.

Rotate a token from its page under Integrations in the Console or with POST /api/v1/profile/api-token/regenerate and POST /api/v1/profile/git-token/regenerate. The new secret is returned only in that answer, and the old token stops working immediately.

GET /api/v1/profile, called with the API token, returns the Git Access Token in clear as git_token.secret, so an agent that only holds the API token can read it and push code without ever opening the console.

Organizations

Every app, service, domain and operation belongs to an organization.

  • Endpoints that list or create take ?organization=<id> as a query parameter, never as a field in the request body: a body rejects any key it doesn't expect with 400 code validation_error.
  • An endpoint that lists without that query parameter uses the current organization of your user.
  • An endpoint that creates, or changes the organization itself such as its billing details, needs it whenever your user belongs to more than one organization, and answers 400 code organization_required without it; with exactly one organization it uses that one.
  • Endpoints that address one object by id need no organization at all.
curl -sS -H "Authorization: Bearer $JD_TOKEN" https://jetdeploy.com/api/v1/organizations
curl -sS -H "Authorization: Bearer $JD_TOKEN" "https://jetdeploy.com/api/v1/apps?organization=3"

GET /api/v1/organizations lists the ones you belong to, with is_current on the default one; POST /api/v1/organizations/<id>/select makes another one the default, for the console and for the lists alike.

GET /api/v1/me tells you which user and which organization a token is working as. GET /api/v1/apps and GET /api/v1/services also take a ?name= query parameter, an exact match, to look one up by name instead of paging through the whole list.

Every organization also carries two address lists, both shown on the console dashboard:

  • inbound_ips: the public IP addresses your Apps and exposed Services are reached at. A custom domain points at them with one A record per address, apex and subdomains alike, and a firewall that must allow outgoing connections to an exposed Service allows all of them.
  • outbound_ips: the public IP addresses the traffic of your Apps and Services leaves from to the internet. A destination that whitelists by source IP (a database, an API, a partner's firewall) must allow all of them.

Errors

Every error, whatever the status, has the same shape: a machine readable code, a message for humans and a hint saying what to do about it, which is often the exact next call to make.

HTTP/1.1 409 Conflict

{"code": "no_push",
 "message": "This app has no pushed code to deploy",
 "hint": "push to https://jetdeploy.com/git/my-app first"}

Validation errors add an errors object with the messages per field, under the field name or under __all__ when they belong to no field in particular:

HTTP/1.1 400 Bad Request

{"code": "validation_error",
 "message": "The request is not valid",
 "hint": null,
 "errors": {"name": ["Enter a valid value."]}}

Every text the API takes must be valid UTF-8 and carry no null character, wherever it sits:

  • a plain field of the body;
  • a value of a mapping, such as the environment variables;
  • an item of a list, such as the command of an exec;
  • a query parameter;
  • a piece of the URL, such as the name of an environment variable.

Text that is not is refused with 400 code validation_error, under the name of the field or query parameter it came in, or under errors.__all__ when it came in the URL itself.

A name rejected on an App, a Service or a process (see Names) still answers 400, with a code that says which of three things happened. The message lands under errors.__all__, except name_clash on a process, which lands under errors.name:

  • name_taken: an active resource of the same kind already has that name in the organization the call targets (for a process, another process of the same App). The hint names it, e.g. GET /api/v1/apps/<id> for an App, or GET /api/v1/apps/<app_id>/pods/<id> for a process.
  • name_unavailable: the name cannot be used here even though nothing of the same kind and organization holds it: another organization holds it, or it belongs to an App where a Service was named (or the reverse) in the same organization. Nothing about who holds it is disclosed; pick another name.
  • name_clash: the name collides with the Kubernetes resource names another workload of the organization renders, once combined with its own suffixes or with the <app>-<process> of a process, or it is a reserved name; pick another name.

Operations

Anything that changes what is running takes time: deploy, apply, restart, start, stop, expose, unexpose and destroy answer 202 with an operation instead of waiting.

A AI Crawler & Bot protection change answers 200 with the new value and, on a deployed app, the operation that brings it live, under operation.

curl -sS -X POST -H "Authorization: Bearer $JD_TOKEN" \
  https://jetdeploy.com/api/v1/apps/42/deploy

HTTP/1.1 202 Accepted

{"id": 871, "kind": "deploy", "target": "app", "target_id": 42, "target_name": "my-app",
 "status": "PENDING", "is_finished": false, "error_message": "",
 "created_at": "2026-09-09T10:12:03.114Z", "started_at": null,
 "commit": "3f9c2a7e5d1b4c8a9e0f6d2b7a1c5e8f4d3b2a10", "cancel_requested": false}

Poll GET /api/v1/operations/871 until is_finished is true: the final status is SUCCESS, FAILURE (with error_message) or REVOKED.

Or let the call wait: GET /api/v1/operations/871?wait_seconds=120 answers as soon as the operation finishes, or with the operation as it is once the wait is over. The wait is at most 240 seconds, and a larger value is lowered to that. A wait counts toward the open streams of the token, see Rate limits.

A SUCCESS deploy or apply can carry a notice in error_message when the AI Crawler & Bot protection of the App is not active yet or could not be activated.

While an operation is still running on the same app, service or process, another one is refused with 409 and code operation_pending, so wait rather than retry blindly.

GET /api/v1/operations lists them, newest first, and POST /api/v1/operations/<id>/cancel stops one that has not finished:

  • Before the pre-deploy script starts, Cancel stops the deploy cleanly.
  • While the script runs, Cancel stops it within seconds, wherever it got to (on a database without transactional DDL, such as MariaDB or MySQL, a migration can be left half done), and the new version is never rolled out.
  • Once the script has finished, the new version is already rolling out and Cancel cannot undo it: it only ends the operation, so that another deploy can start, and a deploy whose rollout is already complete is reported as succeeded.

commit is the pushed commit the operation ships: the one a deploy builds, or the one a rollback returns to. It is null for an operation that ships no commit, such as an apply of stored changes, a restart or a stop.

An operation the cancel reached before it finished carries cancel_requested true from then on. Once it is finished:

  • SUCCESS: it finished before the cancel could take effect, so all of it is live.
  • REVOKED: the cancel ended the operation, which does not mean nothing changed: a new version or image already rolling out when the cancel came can go live anyway. Read what runs from current in GET $API/apps/42/releases and from GET $API/apps/42.
  • FAILURE: it failed before the cancel took effect.

The app's own status describes its running processes, not the deploy:

  • created: never deployed.
  • ready: every process is ready.
  • ready_partial: some processes are stopped and the others are not, with none starting or stopping.
  • starting: at least one process is starting.
  • stopping: at least one process is stopping.
  • stopped: every process is stopped.
  • unknown: the processes are in none of these combinations.

On a first deploy it stays created through the build and the pre-deploy script, since there is no process yet to report a status for. It only moves once the new version starts rolling out (it can read stopped for a moment, then starting).

Follow the deploy itself through the app's pending_operation and next_step, and through the operation's own is_finished and status.

Your first deploy, call by call

The eight calls that take an empty account to a running app. The examples use:

export JD_TOKEN=<your API token>
export JD_GIT_TOKEN=$(curl -sS -H "Authorization: Bearer $JD_TOKEN" \
  https://jetdeploy.com/api/v1/profile | jq -r .git_token.secret)
API=https://jetdeploy.com/api/v1
AUTH="Authorization: Bearer $JD_TOKEN"

The API token alone is enough here: GET /api/v1/profile hands back the Git Access Token secret too, so an agent never needs the console to get it.

  1. Create the app. The answer carries:

    • its id;
    • its git_remote_url;
    • its url, where it will answer once deployed;
    • its organization, the id of the organization it belongs to;
    • a next_step telling you what is missing.

    name shares one name space with every App and Service on JetDeploy: a taken one is refused with 400 (see Names).

    curl -sS -X POST -H "$AUTH" -H 'Content-Type: application/json' \
      -d '{"name": "my-app", "branch": "main"}' $API/apps
  2. Set the environment variables. GET $API/apps/42/envs answers the flat map of the whole set, {"KEY": "value", ...}.

    • PUT $API/apps/42/envs takes the same shape as its request body, {"envs": {...}}, and replaces the whole set, so the keys you leave out are deleted. Its answer wraps the new set together with apply_required and pending_changes: {"envs": {...}, "apply_required", "pending_changes"}.
    • PATCH $API/apps/42/envs with {"envs": {...}} changes only the keys you send: a string value creates or sets the key, null deletes it, and every key you leave out stays as it is. Every change is made or none is: a refused key or value leaves all of them unchanged. It answers like PUT, with the whole resulting set.
    • POST $API/apps/42/envs with {"key": "...", "value": "..."} adds one key and answers 201.
    • GET and PATCH (body {"value": "..."}) on $API/apps/42/envs/<key> read or change one key. All three answer {"key", "value", "apply_required", "pending_changes"}.
    • DELETE on $API/apps/42/envs/<key> answers {"apply_required", "pending_changes"}.

    Every value comes back in clear, secrets included: mask it before printing or logging any of these answers.

    curl -sS -X PUT -H "$AUTH" -H 'Content-Type: application/json' \
      -d '{"envs": {"SECRET_KEY": "change-me", "DATABASE_URL": "postgresql://..."}}' \
      $API/apps/42/envs | jq '.envs | keys'
  3. Add the process. One process per container: the external one is the one that receives HTTP traffic, and its port is required. external is false unless you set it, as a worker needs.

    curl -sS -X POST -H "$AUTH" -H 'Content-Type: application/json' \
      -d '{"name": "web", "port": 8080, "external": true}' $API/apps/42/pods
  4. Set the pre-deploy script, if you need one. Do it now, before the push below: once the app has a pod, a push starts the build and the deploy by itself, so the script has to be in place first.

    It runs after the build and before the new version receives traffic, the right place for database migrations. Its restart rule and time limits are in Pre-deploy script, what a failure does in When a deploy fails.

    curl -sS -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
      -d '{"predeploy_script": "python manage.py migrate --noinput"}' $API/apps/42
  5. Push the code to the git_remote_url of step 1, on the branch of the app, with a one-shot credential helper carrying your Git Access Token as the password so it is never stored on disk. This is the only step that is not an API call.

    When the app already has a pod, the push itself starts the build and the deploy: the push output says "deploy started: operation 871" and "follow it at https://jetdeploy.com/console/apps/42 or through the API at GET /api/v1/operations/871".

    A pipeline that just pushed can also read the operation from latest_push.operation of GET $API/apps/42 instead of parsing git's output: the earliest successfully queued operation of this push visible to you, null until one exists.

    Once set it stays, so a further deploy of the same push, by you or someone else, queues an operation of its own that this field keeps ignoring.

    git -c credential.helper= -c credential.helper='!f() { echo username=git; echo "password=$JD_GIT_TOKEN"; }; f' \
      push https://jetdeploy.com/git/my-app HEAD:refs/heads/main
  6. Deploy, when the push did not. Needed only if the pod was added after the push, or to redeploy the same push again.

    GET $API/apps/42 shows a deploy already running in pending_operation, and its next_step says what to wait for. A 409 operation_pending here means one is already running, so wait for it instead of retrying.

    curl -sS -X POST -H "$AUTH" $API/apps/42/deploy
  7. Poll the operation until is_finished is true. A build takes minutes: poll every few seconds, and read the build output live with $API/apps/42/deploy-logs/tail or a page of it with $API/apps/42/deploy-logs.

    The outcome line of the tail and of the last page (stream outcome) carries level success, error or warn as the operation ends SUCCESS, FAILURE or REVOKED (cancelled). success is not one of the values the level filter accepts.

    curl -sS -H "$AUTH" $API/operations/871
  8. Watch it run. The app answers at its url (https://my-app.jetdeploy.app). The runtime logs stream as they arrive, and GET $API/apps/42 shows the status of the app and of every process.

    Its domains there are summaries (id, name, status, url); the full domain, with its routing_mode and its records, is at GET $API/domains/<id>.

    curl -sS -N -H "$AUTH" "$API/apps/42/runtime-logs/tail?timeout=60"

Applying changes

Later changes follow the same pattern: change what you want, then apply it with POST $API/apps/42/apply, whether the app is running or stopped.

Every answer that carries apply_required also carries pending_changes, the list of stored changes an apply would bring live. That list also holds a platform-driven change, such as a new image on an app already deployed.

  • apply_required is true exactly while the list is not empty, whether or not an operation happens to be running on the app or one of its pods at the moment.
  • On an app that was never deployed, both stay empty, even while its first deploy is running, since a deploy already carries every stored change: check next_step or call POST $API/apps/42/deploy instead.
  • A release entry with action unrecorded means apply is needed to bring the app in line with its current settings, and records it for next time.
  • A default_host entry, with identifier <previous host> -> <new host>, means the platform host of the App moved. The App answers on the new host from the next apply.
  • Neither predeploy_script nor its resource limits ever appear in pending_changes: an apply does not run the pre-deploy script, so a change to either only takes effect at the next deploy or push.

POST $API/apps/42/apply itself is refused with 409 and code operation_pending while an operation is already running on the app or one of its pods. pending_operation and next_step say what to wait for; apply once it finishes.

On an app never deployed that has a push, the apply deploys that push and answers the deploy operation. With no push yet it is refused with 409 and code not_deployed: push the code first.

A change can also bring itself live: add ?apply=true to the call that makes it (environment variables, pods, redirect rules, attaching or detaching a domain). Once the change is stored the app is applied in the same call, and the answer carries that operation under operation; without the flag operation is null.

curl -sS -X PATCH -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"envs": {"LOG_LEVEL": "debug"}}' "$API/apps/42/envs?apply=true"
  • While an operation is running on the app or one of its pods, the call is refused with 409 and code operation_pending, and nothing is changed.
  • On an app never deployed and never pushed to it is refused with 409 and code not_deployed, and nothing is changed.

Rolling back

GET $API/apps/42/releases lists every image the app has run, one release per image at the latest time it went live, newest first (limit, default 20, up to 100). A deploy, an apply or a rollback puts an image live, and so does one that ended FAILURE or REVOKED after its image was already rolling out. Each release has:

  • id, still valid for a rollback after the same image runs again, and deployed_at and operation, the latest operation that put the image live.
  • commit, the pushed commit its image was built from, and image, the tag of that image.
  • current: the app runs that image now; exactly one release carries it.
  • available: the image is still in the registry, which keeps the image the app runs and the last three builds; null when the registry could not be read.
curl -sS -X POST -H "$AUTH" -H "Content-Type: application/json" \
  -d '{"release": 318}' $API/apps/42/rollback

The rollback answers 202 with an apply operation whose commit is the commit rolled back to; poll it like any other. It rolls out the image of that release with the configuration stored now: environment variables, pods, redirect rules and domains as they are saved, including changes not applied yet.

  • Only the image goes back: the pre-deploy script is not run.
  • Database migrations and other changes a later deploy made to data are not undone, so the earlier code has to work with them.
  • A stopped app, or a stopped pod, stays stopped.
  • If a deploy or another rollback puts a different image live before the rollback starts, it ends FAILURE without changing anything: list the releases again.
  • The next push deploys the new commit as usual.

A release of another app is refused with 404; with 409 a never deployed app (not_deployed), the release whose image the app already runs (already_current), an image no longer in the registry (image_unavailable) and an operation still running on the app or one of its pods (operation_pending). 503 registry_unavailable means the registry could not be checked: retry shortly. The MCP tools are list_releases and rollback_app.

Data services

One call creates a data service and deploys it. kind is one of postgresql, mariadb, redis, opensearch and rabbitmq; GET $API/catalog/services lists the engines with the settings each one takes in attrs, their types and their defaults.

curl -sS -X POST -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"name": "my-db", "kind": "postgresql", "storage_size": 10, "attrs": {"database_name": "app"}}' \
  $API/services > /tmp/service.json
jq '.credentials |= (if . then map_values("***") else . end)' /tmp/service.json
DB_PASSWORD=$(jq -r '.credentials.password' /tmp/service.json)
  • storage_size (GiB) goes either at the top level of the request or inside attrs, never both: sent in both places, it is refused with 400 code storage_size_twice.
  • redis takes no storage_size at all, and sending one, at the top level or inside attrs, is refused with 400. It takes attrs.memory instead, in MiB, multiples of 128 from 128 to 16384, default 256 and fixed once the service is created (see Storage and memory).
  • name becomes the service's internal_host, and follows the rules in Names.

POST $API/services answers at once, in created status, with:

  • its organization, the id of the organization it belongs to;
  • its pending_operation, the deploy just queued;
  • everything an app needs to connect: credentials (username, password and, depending on the engine, the database name or a management endpoint), with internal_host and port;
  • the size of its volume in GiB as storage_size (null while the service has no volume) and, for a redis, its memory in MiB as memory (null for every other engine), which every service listed by GET $API/services carries too.

Both the create and the get answer carry the password in clear: mask credentials before printing or logging them.

The deploy itself usually takes about a minute, and up to 1200 seconds before it gives up. The service accepts connections once GET $API/services/17 shows status ready.

If its pending_operation turns null while status is not ready, the deploy failed and deploy_failed is true: read the error of that operation and retry with POST $API/services/17/deploy.

An app that needs it at deploy time, such as a pre-deploy database migration, should wait for ready before its first push.

JetDeploy sets none of this on your app automatically: read it from this answer and set it yourself with PATCH $API/apps/42/envs, either as separate variables or composed into one such as DATABASE_URL (for Redis, a redis://default:<password>@<internal_host>:<port> URL, no TLS). A generated password is letters and digits only, so it needs no percent-encoding in a URL.

POST $API/services/17/expose with {"allowed_cidrs": ["203.0.113.10/32"]} publishes it outside the platform, and exposed_endpoint then says where. A host:port endpoint is a TCP port passed through as is: see Exposing to the internet.

Domains

Claiming a domain returns the records it needs:

  • dns_target: the IP addresses, each one the value of an A record on the domain in direct mode, or what your proxy must forward to in proxy mode;
  • verification_record: a TXT record, null unless the domain is proxied or another organization holds the name or held it recently.
curl -sS -X POST -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"name": "www.example.com"}' $API/domains

curl -sS -X POST -H "$AUTH" $API/domains/9/validate

Validation checks your authoritative nameservers right away. When a record is still missing the answer is 409 with code domain_unvalidated, and the hint spells out the records to create:

HTTP/1.1 409 Conflict

{"code": "domain_unvalidated",
 "message": "Domain unvalidated, please retry",
 "hint": "Records to create: A www.example.com -> 52.57.53.134; A www.example.com -> 3.73.8.244; A www.example.com -> 3.123.207.124. Create the DNS records above, wait for them to propagate and call this again"}

Attach it to the app with POST $API/apps/42/domains and {"domain_id": 9} at any time, before or after validation: the domain is not routed until it is both validated and applied.

That answer carries apply_required and pending_changes, the same fields the app, pod, single-env (POST on .../envs, PATCH on .../envs/<key>) and redirect-rule answers carry. Attaching an already-routed domain again is a no-op and answers apply_required: false.

Once it is validated, call POST $API/apps/42/apply for the certificate and the route to go live. The certificate is requested in the background from there: GET $API/domains/9 carries certificate, whose status is:

  • issued once HTTPS is ready on the domain.
  • pending while it is still being requested (most finish within a couple of minutes, and while it stays pending message says what cert-manager is waiting for).
  • failed when the last attempt did not succeed (message says why and what to expect, retry_at the earliest time the certificate authority accepts a new order for the domain, when the failure was a rate limit).
  • none when no certificate exists for the domain yet (message says what is needed or that it is being requested).

Poll it after attaching and applying rather than assuming HTTPS is live right away.

PATCH $API/domains/9 switches between direct and proxy routing. Switching the mode of a validated domain makes it unvalidated: create the records of the new mode from the answer, validate it again and apply the app again.

DELETE $API/apps/42/domains/9 detaches it again and answers {"apply_required", "pending_changes"}: on a deployed app, apply again to stop serving it. Deleting a redirect rule answers the same way.

The domain embedded in the app's own answer is a summary (id, name, status, url); read the full domain, with its routing_mode, its records and certificate, at GET $API/domains/9.

One-off commands

A one-off command runs in a running process and streams its output back. There is no shell unless you ask for one, so pass ["sh", "-c", "..."] when you need pipes or redirections.

curl -sS -N -X POST -H "$AUTH" -H 'Content-Type: application/json' \
  -d '{"command": ["sh", "-c", "python manage.py migrate --noinput"], "timeout": 120}' \
  $API/apps/42/pods/7/exec

The answer is chunked application/x-ndjson: one JSON object per line, flushed as it is produced. Read it line by line; a stream that ends on its own sends a last line saying why it ended.

{"stream":"stdout","data":"Applying workload.0042... "}
{"stream":"stdout","data":"OK\n"}
{"exit_code":0,"reason":"completed","message":null,"stopped":false}

Exec chunks carry data and arrive unaligned with lines, so a client must concatenate them.

reason is one of:

  • completed
  • timeout
  • output_limit
  • pod_gone
  • access_revoked
  • error

A command may run 60 seconds by default and at most 600: a longer timeout is clamped.

Whenever the stream ends before the command finishes on its own (the timeout elapses, the output reaches its cap with output_limit, you stop reading, your access to the organization is revoked, the pod goes away or the connection fails), the command is stopped together with the processes it started in its process group: they get SIGTERM, then SIGKILL 3 seconds later. The end line then carries stopped true.

In an image without /bin/sh the command cannot be stopped: it keeps running in the container and stopped is false.

Log tails

The log tails are chunked ndjson too, but each line has a different shape: a log line carries message (not data) already complete, one line per line.

  • $API/apps/42/runtime-logs/tail (filters: pod, level, contains)
  • $API/apps/42/deploy-logs/tail (operation, the most recent deploy when left out, and contains)
  • $API/services/17/logs/tail

Their lines are log lines, keep-alive pings and one final end line. A line from the deploy tail also carries stream (build, predeploy or outcome), the same field the paged deploy-log rows carry; a runtime or service tail line carries it too, as an empty string:

{"timestamp":"2026-09-09T10:12:04.881Z","level":"info","message":"GET / 200","pod":"web","container":"web","ns":"1788948724881000000","cursor":"1788948726031000000","key":"6c4198492dad67d0","stream":""}
{"ping":true}
{"end":"timeout","cursor":"1788948784000000000"}

A tail stays open 300 seconds by default and at most 3600, then ends with end set to one of:

  • timeout
  • source_gone
  • access_revoked
  • error

The deploy tail is the one that finishes on its own: when the run ends it emits the outcome line saying whether the deploy succeeded or failed, then closes with end set to source_gone, so an agent can wait on it instead of polling.

Resuming a tail

Without a cursor the runtime and service tails start from now, and the deploy tail from the beginning of the run, with its first lines.

To keep following, call it again with ?cursor= set to the cursor of the end line, or to the cursor of the last line you handled when the stream broke off before its end line, passed back as it is.

The new tail can repeat lines from 15 (or 60) seconds before the cursor onward. Every line carries a key, the same one a page read with the same filters gives it (a field:value search changes it): keep the keys of the lines you handled from that point on and skip the ones you already handled.

A deploy tail resumed after the outcome sends the outcome line again, past the cursor rather than among the repeated lines: skip it by its key, and resume from its cursor, not its ns.

To follow a source, ask for a long timeout. Bound a tail with timeout, not with a number of lines: lines that share a nanosecond can carry the same cursor, so count only the lines whose key is new to you.

Log pages

To read all of a time range, or to search what is already there, read pages instead:

  • $API/apps/42/runtime-logs
  • $API/apps/42/deploy-logs
  • $API/services/17/logs

A page answers {"rows": [...], "next_cursor", "prev_cursor", "has_more", "window": {"from", "to"}}: the matching lines oldest first, the window the filters resolved to, and the cursors to keep paging.

{"rows": [
   {"ns":"1788948724881000000","timestamp":"2026-09-09T10:12:04.881000Z","level":"info",
    "message":"GET / 200","pod":"web","container":"web","key":"6c4198492dad67d0"},
   {"ns":"1788948725104000000","timestamp":"2026-09-09T10:12:05.104000Z","level":"info",
    "message":"GET /health 200","pod":"web","container":"web","key":"aa0c71751b90a8e2"}
 ],
 "next_cursor": "1788948724881000000:6c4198492dad67d0",
 "prev_cursor": "1788948725104000000:aa0c71751b90a8e2",
 "has_more": true,
 "window": {"from":"2026-09-09T04:12:05.500000Z","to":"2026-09-09T10:12:05.500000Z"}}

Each row has ns, timestamp, level, message, pod, container and key, the fields of a tail line without its cursor and stream.

Read with the same filters, a row and a tail line of the same log line carry the same key, so a client that pages and then tails can skip the lines it already has.

A row of $API/apps/42/deploy-logs adds stream (build, predeploy or outcome), and its page also carries operation (the run the lines come from) and operations (the runs still within the log retention).

Paging

A cursor is a position, <ns>:<key>, usually taken from one of the rows of the page it came from; pass it back as it is.

  • Without a cursor the first page is the newest slice of the window, its most recent lines, still oldest first within the page. Pass ?direction=forward to start from the beginning of the window instead.
  • Pass next_cursor back as ?cursor= to keep reading further in the same ?direction= (backward by default, older lines first).
  • To read the other way, pass prev_cursor as ?cursor= and flip ?direction= to the opposite value. On its own, with the direction left as it was, prev_cursor is a position already inside the page you have, so it only reads an overlapping page again instead of moving past it.
  • A page holds limit rows unless the window ends first or a search has read the log store for a few seconds. has_more is false only once the window is read to its end: keep paging with next_cursor while it is true, even after a page with fewer rows, or none.

Log filters

The level filter of the runtime and service tails and pages (the deploy logs take none and ignore it) is one or more of:

  • error, also critical and fatal;
  • warn;
  • info;
  • debug, also trace;
  • other, which matches exactly the lines where no level was detected: they come back with level set to unknown.

Any other value, unknown included, is refused with 400. Pass several by repeating the parameter, as in ?level=error&level=warn: a comma-separated value, such as error,warn, is refused as an invalid choice. How a level is detected is in Log levels.

The contains filter works the same way on every log page and tail: any value that would reveal a secret of the app is masked.

On a deploy run such a search leaves out only the pre-deploy lines. Build lines were masked when they were recorded, so the build lines and the outcome line that match it are still returned.

Rate limits

Every API token gets a fixed number of requests per minute; requests without a token, such as the ones for the schema, are counted per IP address instead.

Over the limit the answer is 429 with code throttled and a Retry-After header saying how many seconds to wait.

An agent polling an operation every second or two stays well within the limit; a tail costs one request however long it stays open.

Open streams are capped separately: at most 8 commands, log tails and operation waits (wait_seconds) can be open at once per token, counted together. Beyond that the answer is 429 with code too_many_streams until one of them ends.

Billing

Billing is per organization, not per process: you are on one fixed-price plan, a reserved amount of vCPU and memory shared by every App and Service you run, whatever their number or their individual CPU and memory usage.

There is no CPU or memory size to set per process or per service; the only size you choose per service is its storage volume.

The pricing page shows the plan of your organization: its vCPU, its memory and its price.

No active plan yet? Get in touch there, or write to hello@jetdeploy.com.

See the billing page for your invoices and payment method.

In the API and the MCP tools, the plan carries the cpu, the memory and the price_per_month of the organization, and GET /api/v1/organizations/<id>/resources measures the usage against them.

The metrics of an App or a data service, GET /api/v1/apps/<id>/metrics and GET /api/v1/services/<id>/metrics, carry the same usage per process twice: under resources as a percentage, and under usage in millicores of CPU and MiB of memory of the plan.

Support

Something is unclear or not working? Write to hello@jetdeploy.com with the name of your App or service and, if you have them, the relevant lines of its logs.

Tell us what is missing from this documentation and it will be added.