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
- Sign in to the Console and create an App, choosing the branch you want to deploy.
- Add the git remote the Console shows you and push that branch.
- Describe the process to run: its command, its listening port, whether it faces the internet.
- 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.
- 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.
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.
-
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
-
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'
-
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
-
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
-
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
-
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
-
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
-
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.