Skip to content

Deploy a Python FastAPI App

This guide walks through deploying a FastAPI application to Satusky. The service caches responses in Upstash Redis to reduce latency and cost on repeated requests, and calls the OpenAI API to generate completions.

What we’re building: A FastAPI app with a Redis cache layer and an OpenAI-backed endpoint. The cache is backed by Upstash so there’s no Redis pod to manage.

my-fastapi/
├── app/
│ ├── main.py ← FastAPI app, routes
│ └── cache.py ← Redis cache helper
├── requirements.txt
├── Dockerfile
└── satusky.toml

All fields live in a single [app] section. Create this at the root of your project directory:

[app]
name = "my-fastapi"
port = 8000
cpu = "0.5"
memory = "512Mi"

name becomes the Kubernetes deployment name and is used as the prefix for every resource Satusky creates (secrets, configmaps, ingress). port must match the port your app listens on. cpu and memory set the container resource limits.

Commit this file. It contains no credentials.

FROM python:3.12-slim
WORKDIR /app
# Copy requirements first so Docker can cache the pip layer
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

The --host 0.0.0.0 flag is required. Kubernetes routes external traffic to the pod IP — if uvicorn binds on 127.0.0.1, every request returns a 502.

Copying requirements.txt before app/ is intentional: Docker’s layer cache means a change to your Python code does not re-run pip install. Only a change to requirements.txt itself triggers a fresh install.

fastapi==0.115.5
uvicorn[standard]==0.32.1
redis==5.2.0
openai==1.54.4
pydantic==2.9.2

Pin exact versions. Floating versions make builds non-reproducible — a new patch release on PyPI can break a deploy weeks after you last touched the code.

For production use with your own code and Dockerfile, run:

Terminal window
1ctl deploy --wait

--wait blocks until all pods are Running. Without it, the deploy exits as soon as the API call succeeds — the pod may still show Pending when you check status right afterward. For first deploys and CI, always use --wait.

Satusky builds your image in the cloud via Kaniko — no local Docker required. Expected output:

💡 Packaging build context...
💡 Submitting build to cloud...
💡 Build ID: bld_04m1x8p3q6
Step 1/5: Building image (cloud) my-fastapi ✓
Step 2/5: Creating/updating deployment my-fastapi ✓
Step 3/5: Configuring services my-fastapi ✓
Step 4/5: Setting up environment and storage my-fastapi ✓
Step 5/5: Configuring public routing and dependencies my-fastapi ✓
💡 Generated new domain: quietpanda-r7w4p1.satusky.com
✅ 🚀 Deployment for my-fastapi is successful! Your app is live at: https://quietpanda-r7w4p1.satusky.com
Deployment ID: 7f1fab9e-5f87-4612-b306-3da846b95d18
💡 Waiting for deployment to become healthy...
✅ Deployment is healthy — pods Running

When using a pre-built image (e.g. --image nginx:alpine), the build step is skipped and the output starts at Step 2:

💡 Using pre-built image: nginx:alpine
Step 2/5: Creating/updating deployment my-fastapi ✓
Step 3/5: Configuring services my-fastapi ✓
Step 4/5: Setting up environment and storage my-fastapi ✓
Step 5/5: Configuring public routing and dependencies my-fastapi ✓
💡 Generated new domain: wisebear-7iz1u6h.satusky.com
✅ 🚀 Deployment for my-fastapi is successful! Your app is live at: https://wisebear-7iz1u6h.satusky.com
Deployment ID: 9a3471b9-e897-442b-bf78-1d7cf66244b4
💡 Waiting for deployment to become healthy...
💡 Deployment status: NotReady (0 pct)
✅ Deployment is healthy — pods Running

The URL is a randomly generated subdomain. Note it from the deploy output, or fetch it later with 1ctl -o json deploy get.

Terminal window
1ctl deploy get
Deployment Details
──────────────────
Deployment ID: 9a3471b9-e897-442b-bf78-1d7cf66244b4
Status: completed
URL: https://wisebear-7iz1u6h.satusky.com
Deployed to machines: c7d2a022-07bf-41f3-b51c-5ebb27365fc4
Type: production
Region:
Zone:
Version: alpine
Port: 8000
CPU Request: 0.5
Memory Request: 512Mi
Memory Limit: 512Mi
Created: just now
Last Updated: just now

The Status: completed means the deploy pipeline finished. For pod health, use deploy status.

Terminal window
1ctl deploy status
Status: Running
Message: Deployment is running normally
Progress: 100%

Status: Running and Progress: 100% mean all pods are healthy. If you see NotReady, wait a few seconds and re-run — the pod may still be pulling the image or starting up.

Your app needs the Upstash Redis connection string and the OpenAI API key. These are credentials — use 1ctl secret create, not 1ctl env create. Secrets are encrypted with AES-256-GCM before storage and are never returned by any CLI command after this point.

Terminal window
1ctl secret create \
--kv REDIS_URL=rediss://default:[email protected]:6380 \
--kv OPENAI_API_KEY=sk-proj-T3BlbTJ...

Expected output:

✅ Secret my-fastapi created successfully

Satusky stores each key in a Kubernetes Secret named my-fastapi-secrets. The keys are stored under their lowercased, hyphenated names (e.g. REDIS_URL becomes redis-url), and the deployment’s pod spec is updated to reference them via secretKeyRef. Your app reads them as normal environment variables.

secret create merges. Running it again with additional or updated keys writes only those keys and leaves existing ones untouched. There is no way to accidentally overwrite a key you did not pass.

Secrets and environment variable changes are not picked up by running pods automatically. Restart to roll new pods with the updated configuration:

Terminal window
1ctl deploy restart
💡 Initiating rolling restart for deployment 9a3471b9-e897-442b-bf78-1d7cf66244b4...
✅ Rolling restart initiated. Pods are being replaced one by one.
💡 Use '1ctl deploy status --deployment-id 9a3471b9-e897-442b-bf78-1d7cf66244b4' to monitor progress.

A rolling restart replaces pods one at a time, so your app stays available during the restart. The deployment ID in the status hint is the same ID shown in deploy get.

Verify Redis is reachable by checking the logs after the new pod comes up:

Terminal window
1ctl logs --tail 20

You should see a line like:

[2026-04-28 09:16:46] [my-fastapi-758b8f5dcd-xjh5j] Redis connection established: us1-kind-crane-00000.upstash.io:6380

If you see a connection error instead, double-check the REDIS_URL value — Upstash uses rediss:// (with two s’s) for TLS.

Non-sensitive runtime config goes through 1ctl env create. These values are stored in a Kubernetes ConfigMap and are readable via 1ctl env list, which is fine for things like ENVIRONMENT=production or LOG_LEVEL=info — but not for credentials.

Terminal window
1ctl env create \
--env ENVIRONMENT=production \
--env LOG_LEVEL=info

Expected output:

✅ Environment my-fastapi created successfully

Like secret create, env create merges — repeated calls add or overwrite only the keys you specify. After running this, the deployment’s pod spec will reference both the secret keys (via secretKeyRef) and the configmap keys (via configMapKeyRef):

REDIS_URL → secretKeyRef → my-fastapi-secrets / redis-url
OPENAI_API_KEY → secretKeyRef → my-fastapi-secrets / openai-api-key
ENVIRONMENT → configMapKeyRef → my-fastapi-environments / environment
LOG_LEVEL → configMapKeyRef → my-fastapi-environments / log-level

Restart to pick up the new values:

Terminal window
1ctl deploy restart

LOG_LEVEL=info is useful during initial setup. Once things are stable you may want to remove it entirely and let your app fall back to its default. To remove a single env var key:

Terminal window
1ctl env unset --key LOG_LEVEL
✅ Key "LOG_LEVEL" removed from environment

Only LOG_LEVEL is removed. ENVIRONMENT=production is untouched. The configmap now only contains environment. Restart to apply:

Terminal window
1ctl deploy restart
Terminal window
1ctl logs --tail 20
Pod Logs
────────
[2026-04-28 09:12:03] [my-fastapi-758b8f5dcd-xjh5j] POST /complete cache=miss latency=1240ms
[2026-04-28 09:12:09] [my-fastapi-758b8f5dcd-xjh5j] POST /complete cache=miss latency=1185ms
[2026-04-28 09:12:14] [my-fastapi-758b8f5dcd-xjh5j] POST /complete cache=hit latency=4ms
[2026-04-28 09:12:21] [my-fastapi-758b8f5dcd-xjh5j] POST /complete cache=hit latency=3ms
[2026-04-28 09:12:30] [my-fastapi-758b8f5dcd-xjh5j] GET /health status=200 latency=1ms
---
Showing last 5 lines

Each line is prefixed with a timestamp and the pod name in square brackets. cache=miss means the response was fetched from OpenAI and written to Redis. cache=hit means it came from Redis — no OpenAI call, ~300x faster.

--tail N shows the last N lines from the current pod. Omit it to get the default tail. The footer Showing last N lines confirms how many lines were returned.

1ctl deploy get returns the full deployment spec. The -o json flag outputs machine-readable JSON, which is useful for scripts and for verifying that your satusky.toml values landed correctly.

Note that -o json is a global flag — it must come before the subcommand:

Terminal window
# Correct
1ctl -o json deploy get
# Wrong — will not work
1ctl deploy get -o json
Terminal window
1ctl -o json deploy get
{
"deployment_id": "9a3471b9-e897-442b-bf78-1d7cf66244b4",
"user_id": "7aeb1c24-b7fd-46d4-be7a-a18b43cdd5d2",
"hostnames": [
"c7d2a022-07bf-41f3-b51c-5ebb27365fc4"
],
"type": "production",
"zone": "",
"region": "",
"ssd": "true",
"gpu": "false",
"namespace": "org3-b322955e",
"replicas": 1,
"image": "nginx:alpine",
"app_label": "my-fastapi",
"port": 8000,
"cpu_request": "0.5",
"memory_request": "512Mi",
"memory_limit": "512Mi",
"env_enabled": false,
"secret_enabled": false,
"volume_enabled": false,
"status": "completed",
"environment": "production",
"marketplace_app_name": "",
"domain": "https://wisebear-7iz1u6h.satusky.com",
"created_at": "2026-04-28T09:15:53.412186+08:00",
"updated_at": "2026-04-28T09:15:53.412186+08:00"
}

If cpu_request or memory_request don’t match your satusky.toml, the most common cause is that the deploy used a cached spec from a previous run. Re-deploy with 1ctl deploy --wait to force the spec to sync.

The domain field holds the full HTTPS URL. The image field shows the image that is currently running. Use these fields in scripts.

If you need to rotate the key — a security incident, a billing limit, a key leak — update just that secret:

Terminal window
1ctl secret create --kv OPENAI_API_KEY=sk-proj-NewKeyHere...
✅ Secret my-fastapi created successfully

secret create merges: only OPENAI_API_KEY is overwritten. REDIS_URL is unchanged. You can verify by checking that both keys still exist (key names only, values are never shown):

Terminal window
kubectl -n <your-namespace> get secret my-fastapi-secrets \
-o jsonpath='{.data}' | python3 -c \
"import sys,json; [print(k) for k in sorted(json.loads(sys.stdin.read()).keys())]"
# openai-api-key
# redis-url

The new key is not active until running pods restart. Restart without a rebuild:

Terminal window
1ctl deploy restart

Or redeploy if you also changed code:

Terminal window
1ctl deploy --wait

To permanently remove a secret key:

Terminal window
1ctl secret unset --key REDIS_URL
✅ Key "REDIS_URL" removed from secrets

Only REDIS_URL is removed. All other secret keys are untouched. Restart to apply.

Terminal window
1ctl deploy releases
VERSION IMAGE STATUS DEPLOYED
─────── ──────────── ────── ────────────
1 nginx:alpine active 1 minute ago

Columns: VERSION is an incrementing integer, IMAGE is the image tag that was deployed, STATUS is active for the currently running release, and DEPLOYED is a relative timestamp.

The -o json flag makes 1ctl deploy get composable with jq. A common CI pattern: deploy with --wait to ensure pods are healthy, then extract the URL for a smoke test.

Terminal window
# Deploy and wait for healthy pods
1ctl deploy --wait
# Extract the app URL (the field is 'domain', not 'url')
APP_URL=$(1ctl -o json deploy get | jq -r '.domain')
# Run a smoke test against the live deployment
curl -sf "${APP_URL}/health" | jq '.status'

Expected output from the smoke test:

"ok"

The JSON field is .domain — not .url. If curl -sf exits non-zero (a 4xx or 5xx response, or a connection failure), the script fails and your CI pipeline marks the deploy as failed. Using --wait before the URL extraction ensures you’re testing against a running pod, not one still starting up.

When you no longer need the deployment, destroy it and all associated resources (service, public route, configmap, secrets):

Terminal window
1ctl deploy destroy -y
💡 Destroying deployment 9a3471b9-e897-442b-bf78-1d7cf66244b4...
✅ Deployment 9a3471b9-e897-442b-bf78-1d7cf66244b4 destroyed successfully

The -y flag skips the confirmation prompt. After destroy, verify that deployment-owned resources are gone:

Terminal window
kubectl -n <namespace> get deploy,svc,httproute,ingress,pvc

Current v1 work should make route and volume cleanup explicit in CLI output instead of relying on a generic success message.

TaskCommand
First deploy (with cloud build)1ctl deploy --wait
Check deployment details1ctl deploy get
Check pod health1ctl deploy status
Set secrets (Redis URL, API keys)1ctl secret create --kv KEY=VALUE
Remove a secret key1ctl secret unset --key KEY
Set env vars (non-sensitive config)1ctl env create --env KEY=VALUE
Remove an env var key1ctl env unset --key KEY
Restart without rebuild1ctl deploy restart
View logs1ctl logs --tail 20
View release history1ctl deploy releases
Check resource allocation (JSON)1ctl -o json deploy get
Extract URL for scripting1ctl -o json deploy get | jq -r '.domain'
Rotate a single secret1ctl secret create --kv KEY=new-value then 1ctl deploy restart
Tear down1ctl deploy destroy -y