Deploy a Python app
Goal: take a Python handler from local file to a published, auto-scaled app on Ignite — including a third-party dependency the runtime doesn’t pre-install — then invoke it and understand what happens on the first (cold) call.
Shape:
handler.py ──► app create ──► draft save ──► draft publish ──► invoke
(def handler) (metadata, (code → (draft → (cold pod
version 0) draft slot) version 1, FQDN) starts 0→1)Prerequisites
- The
dodilCLI installed and authenticated —dodil ignite config set token <token>anddodil ignite config set api_endpoint rpc.dev.dodil.io:443(orexport DODIL_TOKEN=<token>). See CLI Basics. - Your org name for the
org:nameID form. We useacme-corp.
1. Write the handler
The managed Python runtime loads a top-level handler from handler.py and runs the HTTP server around it for you. The contract is:
def handler(payload: bytes, ctx: dict) -> Anypayload— the raw request body (bytes). Parse JSON yourself.ctx— a dict withexecution_id,function_id(org/name), andorganization_id.- Return value — a
dict/listis JSON-encoded, astris UTF-8,bytesare sent verbatim.
This example pulls in tabulate, which is not in the runtime’s pre-installed set — so it also exercises custom dependencies. Create handler.py:
import json
from tabulate import tabulate
def handler(payload, ctx):
try:
event = json.loads(payload) if payload else {}
except (TypeError, ValueError):
event = {}
rows = event.get("rows", [["alice", 1], ["bob", 2]])
return {
"table": tabulate(rows, tablefmt="plain"),
"execution_id": ctx.get("execution_id", ""),
}Pre-installed vs custom deps. The default pool mode only has the runtime’s pre-installed packages (numpy, pandas, requests, httpx, pydantic, boto3, …). Anything else — like
tabulate— needs custom dependencies, which run in dedicated mode (a per-execution pod that pip-installs your deps). Declare them in arequirements.txtnext to your code; the build picks it up when you save the draft from a directory.
Create requirements.txt next to handler.py:
tabulate==0.9.02. Create the app
dodil ignite app create tabler --runtime python --description "Render rows as a text table"The app id is now acme-corp:tabler.
3. Save the code (with the dependency)
draft save uploads code into the mutable draft (version 0). Point -c at the directory so both handler.py and requirements.txt are packaged; the build installs the declared dependency:
dodil ignite draft save acme-corp:tabler -c ./You can confirm the draft slot:
dodil ignite draft info acme-corp:tabler4. Publish
Publishing snapshots the draft into an immutable, numbered version and points active_version at it. (Python is interpreted — no separate compile step; only Rust drafts need dodil ignite draft compile.)
dodil ignite draft publish acme-corp:tablerAfter the first publish the app gets a direct FQDN — `tabler-acme-corp.ignite.dodil.cloud` (port 80). Verify:
dodil ignite app get acme-corp:tabler -o json | jq '{active_version, public_urls}'5. Invoke
dodil ignite invoke acme-corp:tabler -p '{"rows": [["ada", 1], ["grace", 2]]}'{
"table": "ada 1\ngrace 2",
"execution_id": "01J..."
}Or hit the direct FQDN — verbatim pod response, still Bearer-authed:
curl -sS -X POST "https://tabler-acme-corp.ignite.dodil.cloud/" \
-H "Authorization: Bearer $DODIL_TOKEN" \
-H "Content-Type: application/json" \
-d '{ "rows": [["ada", 1], ["grace", 2]] }'6. Inspect the execution
dodil ignite execution get <exec-id> -o json | jq '{status, metrics}'metrics.cold_start_ms is non-zero when the call started a pod from zero; billed_duration_ms is what you’re billed on (execution plus init if cold).
Cold start + scaling
Ignite apps scale to zero when idle, so any invoke is either warm (a pod is already running) or cold (none is — one has to start):
- The first call to a cold app waits, it isn’t rejected. The platform holds the connection open while a pod starts (0→1) and becomes ready, then proxies your request. A custom-dependency (dedicated-mode) app has a longer cold start than a pool-mode one because its pod pip-installs your deps. The execution’s
cold_start_msand the response duration include this wait. - Avoid cold starts by keeping pods warm: set
ScalingConfig.reserved_capacity > 0so that many pods stay running and are exempt from idle teardown. Tune how the app scales up withscale_metric(REQUEST_RATEfor short uniform requests,CONCURRENCYfor variable-duration work) andmax_replicas.
For the full breakdown — the cold-start budget, per-request deadlines, and the warm-pod lever — see API Reference → Cold starts vs warm pods.
Iterating
To ship a change: edit handler.py, draft save again (overwrites the draft slot), then draft publish to cut a new immutable version. The old version stays available — roll back any time with dodil ignite version rollback acme-corp:tabler <version>.
Common gotchas
| Symptom | Cause | Fix |
|---|---|---|
ModuleNotFoundError at invoke time | Dependency not declared, so it ran in pool mode without it | Add it to requirements.txt, re-save the directory, then re-publish |
| Handler never gets called / 500 on every invoke | No top-level handler in handler.py, or it isn’t named handler | Expose def handler(payload, ctx) at module top level |
| Got a string instead of JSON back | Returned a str; the runtime sends strings as UTF-8 text | Return a dict/list to get JSON encoding |
| First call takes seconds, later calls are instant | Cold start (pod scaled from zero) | Expected — set reserved_capacity > 0 to keep pods warm |
See also
- Quickstart — the minimal version of this flow
- Expose an app as an MCP tool — make this app agent-callable
- API Reference → Invocation — Invoke RPC, direct FQDNs, executions
- Core Concepts — code sources, runtimes, scaling config, lifecycle states