Documentation Draft

Everything you need to build, deploy and run your apps and data services on JetDeploy.

Getting started

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

Your first deploy takes five steps:

  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 jetdeployapp.com 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.

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

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. A push before the App has a process 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.

<token> is your Git Access Token, shown on the App page and on the Git Access Token page under Integrations. Prefer to keep it out of your git config? Use https://jetdeploy.com/git/<app> 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. The token works on every App of every organization you belong to: never commit it, regenerate it from the Git Access Token page if it leaks.

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>). Reset the helper list first with an empty -c credential.helper=, or 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.

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>

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.

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"]

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, letters, numbers, underscores or hyphens.
  • 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. 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.
  • 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.

Processes can be started, stopped and restarted one by one from the App page. Stopping the whole App stops every process and keeps your data services running.

HTTPS and proxy headers

The JetDeploy edge terminates HTTPS and forwards the request to your process as plain HTTP, so a framework check like Django's request.is_secure() sees an insecure request unless you tell it where to look. 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 the X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host, X-Forwarded-Port and X-Real-Ip headers the client sent with its own: 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. Your process can trust those five; every other header, such as Forwarded, X-Forwarded-Ssl or CF-Connecting-IP, reaches it exactly as the client sent it. 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.

SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")

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 this process on its internal address (http://<app>-<process>:<port>), which never go through the edge and carry no X-Forwarded-Proto: exempt those paths, or leave the redirect to the edge. Other frameworks: trust X-Forwarded-Proto from the proxy the same way.

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. Changes are applied with a restart of the processes, which the Console does for you when you click Apply.

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

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, so it is the place for steps that change shared state, such as a database migration or a cache warmup. A script that fails is retried once, in a new container, so write it to be safe to run twice, the way a migration already is. The build has 1200 seconds. After it, the script has at most 510 seconds, both attempts included, and the script and the start of the new version together at most 600 seconds. If the build fails, the script fails twice, or either runs out of time, the deploy fails and the previous version, if there is one, keeps serving. Past the time limit the pre-deploy is stopped wherever it got to, whether the script was running or its container never started, and 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. If the new version does not become ready in time the deploy fails too, but the rollout goes on and the new version takes over as soon as it is ready.

The pre-deploy script runs in a container of its own, with no filesystem shared with your processes: anything it writes there never reaches them. A step that produces files — compiling assets, collecting static files — belongs in the build, in your Dockerfile, not in the pre-deploy script. The build 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.

python manage.py migrate --noinput

Logs, metrics and shell

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

The level shown and filtered on is the one the log store detects on each line when it arrives. 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: it is info as soon as it contains info or INFO anywhere, even inside a word or a URL, and that wins over everything else; otherwise it is error for [ERROR], [error], ERR: or err: (not ERROR: or Error:), warn for [WARN], WARN: or the word warning/WARNING anywhere, and debug for debug/DEBUG anywhere. A line with none of these comes back unknown, which is what the other filter matches. To make the filter reliable, log JSON with a level field set to a word.

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 its own storage volume, sized when you create it (for Redis, from the memory you give it) and not resizable afterwards, and a detail page with logs, metrics and the same start, stop and restart controls as an App. There is no CPU or memory size to set per service, see Billing.

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 and, where the engine has them, the username and the database name. 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, such as DATABASE_URL=postgresql://<username>:<password>@<internal endpoint>/<database>. 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.

Domains

Every App answers on its default host under jetdeployapp.com. 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. The Console gives you a TXT record to add at your DNS provider:

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 record in place: it is 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: point the domain at JetDeploy with a CNAME (or an A record for an apex domain) to the value shown on the domain page. 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 origin shown on the domain page, and point the domain at the proxy.

Attach the domain to the App and click Apply before changing your DNS records, so the App is already listening when traffic arrives.

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.

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, and 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 returns the Git Access Token in clear as git_token.secret: it is never hidden, 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>; left out, they use the current organization of your user. 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. GET /api/v1/me tells you which user and which organization a token is working as.

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."]}}

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.

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}

Poll GET /api/v1/operations/871 until is_finished is true: the final status is SUCCESS, FAILURE (with error_message) or REVOKED. 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. Cancelling a deploy while its pre-deploy script runs stops the script within seconds, wherever it got to, and the new version is not rolled out.

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) and a next_step telling you what is missing.

    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 and PUT on $API/apps/42/envs both answer the flat map of the whole set, {"KEY": "value", ...}: the PUT request body is {"envs": {...}}, but the answer is not wrapped, and PUT replaces the whole set, so the keys you leave out are deleted. 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"}; DELETE on $API/apps/42/envs/<key> answers 204. 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 'keys'
  3. Add the process. One process per container: the external one is the one that receives HTTP traffic, and its port is required.

    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. A failing script is retried once, so write it to be safe to run twice. The script may take at most 510 seconds, both attempts included, and at most 600 seconds together with the start of the new version; the build before it has its own 1200 seconds. If the script fails twice or runs out of time, the deploy fails and the previous version, if there is one, keeps serving; past the time limit the pre-deploy is stopped wherever it got to, even if its container never started, and a script stopped halfway can leave a MariaDB or MySQL database half migrated. Run a step that needs longer outside the script: deploy without it, run the step once in the new version with POST /api/v1/apps/{app_id}/pods/{pod_id}/exec, then set the script again; until it finishes the new version runs against the old schema, so the migration has to be backward compatible.

    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 "App deploy in progress".

    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.

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

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

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. Fields that need it come back with apply_required: true.

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. storage_size (GiB) goes either at the top level of the request or inside attrs, never both (400 code storage_size_twice). Redis takes neither: its volume is sized from attrs.memory (MB), a top-level storage_size is ignored and one inside attrs is refused with 400 code unknown_attribute.

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)

POST $API/services answers at once, in created status, with its pending_operation for the deploy just queued and everything an app needs to connect, under credentials (username, password and, depending on the engine, the database name or a management endpoint) with internal_host and port. 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. That same answer carries the size of its volume in GiB as storage_size (for Redis, computed from memory; null while the service has no volume). Both the create and the get answer carry the password in clear: mask credentials before printing or logging them. JetDeploy sets none of this on your app automatically: read it from this answer and set it yourself with PUT $API/apps/42/envs, either as separate variables or composed into one such as DATABASE_URL; 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.

Domains

Claiming a domain returns the records it needs: dns_target, the values your A or CNAME record must point at, and verification_record, the TXT record proving you own the name.

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: CNAME www.example.com -> jetdeployapp.com. Create the DNS records above, wait for them to propagate and call this again"}

Once it is validated, attach it to the app with POST $API/apps/42/domains and {"domain_id": 9}, then POST $API/apps/42/apply for the app to start serving it. PATCH $API/domains/9 switches between direct and proxy routing, and DELETE $API/apps/42/domains/9 detaches it again. 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.

Commands and log tails

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; the last line always says why the stream ended.

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

reason is completed, timeout, output_limit, pod_gone, access_revoked or error. A command may run 60 seconds by default and at most 600: a longer timeout is clamped. When it elapses, or when you stop reading, the stream ends but the command is not killed and may keep running in the container: wrap a long command with the timeout utility of the image, as in ["sh", "-c", "timeout 60 ..."], when it has to be stopped for good.

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) and other. other matches exactly the lines where no level was detected, which come back with level set to unknown. Any other value, unknown included, is refused with 400.

The log tails work the same way: $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 $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":"1757412724881000000","stream":""}
{"ping":true}
{"end":"timeout","cursor":"1757412784000000000"}

A tail stays open 300 seconds by default and at most 3600, then ends with end set to timeout, source_gone, access_revoked or 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. Without a cursor a tail starts from now. To keep following, call it again with ?cursor= set to the cursor of the end line, or to the ns of the last line you handled: nothing is repeated between two tails, but a line that reaches the log store late, in the last seconds before that cursor, can be missed; when you need every line, read that stretch again as a page. For a search over what is already there, read a page instead: $API/apps/42/runtime-logs, $API/apps/42/deploy-logs and $API/services/17/logs.

A page answers {"rows": [...], "next_cursor", "prev_cursor", "window": {"from", "to"}}: the matching lines oldest first, the window the filters resolved to, and the cursors to keep paging. A cursor is always <ns>:<key>, taken from one of the rows of the page it came from.

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

Each row has the fields of a tail line: ns, timestamp, level, message, pod and container; 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). 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.

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 and log tails can be open at once per token, counted together, and beyond that the answer is 429 with code too_many_streams until one of them ends.

Connect your agent

The same 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. There is a tool for every API operation except the live streams, the payment methods, the token regenerations and the log histogram and context parts — apps, processes, environment variables, data services, domains, operations, logs, metrics and one-off commands are all there — and they do exactly what the calls on this page do, with the same permissions, the same errors and the same rate limits. The transport is plain HTTP: one POST per message, no websocket.

  • 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.
  • ChatGPT: turn developer mode on, add https://jetdeploy.com/mcp as an MCP server and authorize it the same way.
  • Claude Code: claude mcp add --transport http jetdeploy https://jetdeploy.com/mcp, then /mcp to sign in.

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 your Git Access Token already in it, so 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 is only handed to a connection that was given the write scope: for a read-only connection git_remote_with_token is absent and the secret of git_token in get_profile comes back null.

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, while revoking one is deliberately not a tool: that is the AI Agents & MCP page and DELETE /api/v1/profile/connections/<id>, so no agent revokes a connection of yours. An agent that is granted the read scope alone can read everything and change nothing, and a write it tries anyway is refused with 403 and code insufficient_scope.

Billing

Billing is per organization, not per process: you are on one fixed-price size, 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 (for Redis, the memory its volume is sized from). The pricing page lists every size on sale, highlighting yours once your organization has an active plan; Change plan there requests a different one. See the billing page for your invoices and payment method.

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. This documentation is a draft: tell us what is missing and it will be added.