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.
Prerequisites
Section titled “Prerequisites”1ctlinstalled — see Installation- Authenticated — see Authentication
- An Upstash Redis database (free tier works)
- An OpenAI API key
Project structure
Section titled “Project structure”my-fastapi/├── app/│ ├── main.py ← FastAPI app, routes│ └── cache.py ← Redis cache helper├── requirements.txt├── Dockerfile└── satusky.tomlsatusky.toml
Section titled “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.
Dockerfile
Section titled “Dockerfile”FROM python:3.12-slim
WORKDIR /app
# Copy requirements first so Docker can cache the pip layerCOPY requirements.txt ./RUN pip install --no-cache-dir -r requirements.txt
COPY app/ ./app/
EXPOSE 8000CMD ["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.
requirements.txt
Section titled “requirements.txt”fastapi==0.115.5uvicorn[standard]==0.32.1redis==5.2.0openai==1.54.4pydantic==2.9.2Pin 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.
First deploy
Section titled “First deploy”For production use with your own code and Dockerfile, run:
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_04m1x8p3q6Step 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.comDeployment ID: 7f1fab9e-5f87-4612-b306-3da846b95d18💡 Waiting for deployment to become healthy...✅ Deployment is healthy — pods RunningWhen 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:alpineStep 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.comDeployment ID: 9a3471b9-e897-442b-bf78-1d7cf66244b4💡 Waiting for deployment to become healthy...💡 Deployment status: NotReady (0 pct)✅ Deployment is healthy — pods RunningThe URL is a randomly generated subdomain. Note it from the deploy output, or fetch it later with 1ctl -o json deploy get.
Check deployment details
Section titled “Check deployment details”1ctl deploy getDeployment Details──────────────────Deployment ID: 9a3471b9-e897-442b-bf78-1d7cf66244b4Status: completedURL: https://wisebear-7iz1u6h.satusky.comDeployed to machines: c7d2a022-07bf-41f3-b51c-5ebb27365fc4Type: productionRegion:Zone:Version: alpinePort: 8000CPU Request: 0.5Memory Request: 512MiMemory Limit: 512MiCreated: just nowLast Updated: just nowThe Status: completed means the deploy pipeline finished. For pod health, use deploy status.
Check pod health
Section titled “Check pod health”1ctl deploy statusStatus: RunningMessage: Deployment is running normallyProgress: 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.
Set secrets
Section titled “Set secrets”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.
1ctl secret create \ --kv OPENAI_API_KEY=sk-proj-T3BlbTJ...Expected output:
✅ Secret my-fastapi created successfullySatusky 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.
Restart to apply secrets
Section titled “Restart to apply secrets”Secrets and environment variable changes are not picked up by running pods automatically. Restart to roll new pods with the updated configuration:
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:
1ctl logs --tail 20You should see a line like:
[2026-04-28 09:16:46] [my-fastapi-758b8f5dcd-xjh5j] Redis connection established: us1-kind-crane-00000.upstash.io:6380If you see a connection error instead, double-check the REDIS_URL value — Upstash uses rediss:// (with two s’s) for TLS.
Set environment variables
Section titled “Set environment variables”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.
1ctl env create \ --env ENVIRONMENT=production \ --env LOG_LEVEL=infoExpected output:
✅ Environment my-fastapi created successfullyLike 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-urlOPENAI_API_KEY → secretKeyRef → my-fastapi-secrets / openai-api-keyENVIRONMENT → configMapKeyRef → my-fastapi-environments / environmentLOG_LEVEL → configMapKeyRef → my-fastapi-environments / log-levelRestart to pick up the new values:
1ctl deploy restartRemove an environment variable
Section titled “Remove an environment variable”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:
1ctl env unset --key LOG_LEVEL✅ Key "LOG_LEVEL" removed from environmentOnly LOG_LEVEL is removed. ENVIRONMENT=production is untouched. The configmap now only contains environment. Restart to apply:
1ctl deploy restartView logs
Section titled “View logs”1ctl logs --tail 20Pod 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 linesEach 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.
Check resource allocation with -o json
Section titled “Check resource allocation with -o json”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:
# Correct1ctl -o json deploy get
# Wrong — will not work1ctl deploy get -o json1ctl -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.
Rotate the OpenAI API key
Section titled “Rotate the OpenAI API key”If you need to rotate the key — a security incident, a billing limit, a key leak — update just that secret:
1ctl secret create --kv OPENAI_API_KEY=sk-proj-NewKeyHere...✅ Secret my-fastapi created successfullysecret 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):
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-urlThe new key is not active until running pods restart. Restart without a rebuild:
1ctl deploy restartOr redeploy if you also changed code:
1ctl deploy --waitRemove a secret key
Section titled “Remove a secret key”To permanently remove a secret key:
1ctl secret unset --key REDIS_URL✅ Key "REDIS_URL" removed from secretsOnly REDIS_URL is removed. All other secret keys are untouched. Restart to apply.
View release history
Section titled “View release history”1ctl deploy releasesVERSION IMAGE STATUS DEPLOYED─────── ──────────── ────── ────────────1 nginx:alpine active 1 minute agoColumns: 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.
Scripting with -o json
Section titled “Scripting with -o json”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.
# Deploy and wait for healthy pods1ctl 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 deploymentcurl -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.
Tear down
Section titled “Tear down”When you no longer need the deployment, destroy it and all associated resources (service, public route, configmap, secrets):
1ctl deploy destroy -y💡 Destroying deployment 9a3471b9-e897-442b-bf78-1d7cf66244b4...✅ Deployment 9a3471b9-e897-442b-bf78-1d7cf66244b4 destroyed successfullyThe -y flag skips the confirmation prompt. After destroy, verify that deployment-owned resources are gone:
kubectl -n <namespace> get deploy,svc,httproute,ingress,pvcCurrent v1 work should make route and volume cleanup explicit in CLI output instead of relying on a generic success message.
Summary
Section titled “Summary”| Task | Command |
|---|---|
| First deploy (with cloud build) | 1ctl deploy --wait |
| Check deployment details | 1ctl deploy get |
| Check pod health | 1ctl deploy status |
| Set secrets (Redis URL, API keys) | 1ctl secret create --kv KEY=VALUE |
| Remove a secret key | 1ctl secret unset --key KEY |
| Set env vars (non-sensitive config) | 1ctl env create --env KEY=VALUE |
| Remove an env var key | 1ctl env unset --key KEY |
| Restart without rebuild | 1ctl deploy restart |
| View logs | 1ctl logs --tail 20 |
| View release history | 1ctl deploy releases |
| Check resource allocation (JSON) | 1ctl -o json deploy get |
| Extract URL for scripting | 1ctl -o json deploy get | jq -r '.domain' |
| Rotate a single secret | 1ctl secret create --kv KEY=new-value then 1ctl deploy restart |
| Tear down | 1ctl deploy destroy -y |
Next steps
Section titled “Next steps”- Environment Configuration — staging vs production patterns, multiple config files
- CI/CD Integration — automate deploys from GitHub Actions with smoke tests
- Custom Domains — attach
api.mycompany.com - Autoscaling — scale on CPU or request load