Deploy a Node.js API
This guide walks through deploying a production Node.js Express API to Satusky. You will end up with a running service with automatic HTTPS, encrypted secrets, live log streaming, and a release history you can roll back from. Every step shows the exact output you should see so you always know whether things went right.
What we’re building: A REST API that authenticates requests with JWT tokens and reads from a Neon.tech PostgreSQL database.
Prerequisites
Section titled “Prerequisites”1ctlinstalled — see Installation- Authenticated — run
1ctl auth statusand confirm you see your email - A Neon.tech project with a connection string ready
Project structure
Section titled “Project structure”my-api/├── src/│ ├── index.js ← entry point, Express app│ ├── routes/│ │ ├── auth.js│ │ └── users.js│ └── middleware/│ └── jwt.js├── Dockerfile├── package.json├── package-lock.json└── satusky.tomlDockerfile
Section titled “Dockerfile”Satusky builds your image in the cloud using Kaniko — you do not need Docker installed locally. You just need a valid Dockerfile in your project root.
# ---- builder ----FROM node:20-alpine AS builderWORKDIR /appCOPY package.json package-lock.json ./RUN npm ci --omit=dev
# ---- runtime ----FROM node:20-alpineWORKDIR /appCOPY --from=builder /app/node_modules ./node_modulesCOPY src ./srcCOPY package.json ./EXPOSE 3000CMD ["node", "src/index.js"]The builder stage installs only production dependencies (--omit=dev). The runtime stage copies the node_modules and source — nothing else. This keeps the final image lean and avoids shipping dev tooling.
Bind on 0.0.0.0, not localhost. Kubernetes routes external traffic to the pod IP, not the loopback interface. If your Express app binds on 127.0.0.1, every request returns a 502. Make sure your entry point does this:
const PORT = process.env.PORT || 3000;
app.listen(PORT, '0.0.0.0', () => { console.log(`server listening on 0.0.0.0:${PORT}`);});satusky.toml
Section titled “satusky.toml”Create satusky.toml in the project root:
[app] name = "my-api" port = 3000 dockerfile = "Dockerfile" cpu = "0.5" memory = "512Mi"All fields in satusky.toml live under the single [app] section. The name field becomes the Kubernetes app label — it must be unique within your namespace. port must match the port your app binds on. cpu and memory set the resource limits for your container. Commit this file — it contains no secrets.
What’s deliberately left out: org (taken from your active auth context), deployment IDs (resolved at runtime), and secrets (never go in a file). The CLI never modifies satusky.toml — it only reads it.
Alternatively, run 1ctl init in your project directory and the CLI writes the minimal file for you:
1ctl init✅ Created satusky.toml💡 Edit satusky.toml, then run: 1ctl deployinit writes only name (auto-detected from the directory name) and port (defaulting to 8080). Edit the file to add cpu, memory, and dockerfile before deploying.
Step 1: First deploy
Section titled “Step 1: First deploy”1ctl deploy --wait--wait blocks until pods are Running and exits non-zero on failure. Without it, the deploy request is accepted and the command returns immediately — the pod may still be Pending when you check status. Always use --wait for first deploys and in CI pipelines.
Expected output:
💡 Packaging build context...💡 Submitting build to cloud...💡 Build ID: bld_09p4x2k7n1Step 1/5: Building image (cloud) my-api ✓Step 2/5: Creating/updating deployment my-api ✓Step 3/5: Configuring services my-api ✓Step 4/5: Setting up environment and storage my-api ✓Step 5/5: Configuring public routing and dependencies my-api ✓💡 Generated new domain: braveowl-84ya2ww.satusky.com✅ 🚀 Deployment for my-api is successful! Your app is live at: https://braveowl-84ya2ww.satusky.comDeployment ID: 54cf427b-bb70-4977-9ba7-b9b36c20776d💡 Waiting for deployment to become healthy...✅ Deployment is healthy — pods RunningWhat each step does:
- Step 1 — packages your source directory, uploads it to Satusky’s build service, and Kaniko builds and pushes the image to the private registry. If you pass
--imagewith a pre-built image this step is skipped. - Steps 2–3 — creates or updates the Kubernetes Deployment and ClusterIP Service in your namespace.
- Step 4 — wires up any env vars or secrets you have already set (none yet on a first deploy).
- Step 5 — creates the public route and assigns a random domain.
The URL is always a randomly generated subdomain — something like braveowl-84ya2ww.satusky.com. It is not derived from your app name. The domain is stable: re-deploying the same app reuses the same domain. Only destroying and recreating the deployment generates a new one.
The Deployment ID is printed after the success line. Save it if you want to use --deployment-id flags directly, but most commands auto-resolve from satusky.toml so you rarely need it.
If --wait times out: the deployment was still created. The timeout means the pod didn’t become healthy within 5 minutes — check logs immediately with 1ctl logs stream.
Step 2: Check what was deployed
Section titled “Step 2: Check what was deployed”Before moving on, confirm everything looks right:
1ctl deploy getExpected output:
Deployment Details──────────────────Deployment ID: 54cf427b-bb70-4977-9ba7-b9b36c20776dStatus: completedURL: https://braveowl-84ya2ww.satusky.comDeployed to machines: c7d2a022-07bf-41f3-b51c-5ebb27365fc4Type: productionVersion: alpinePort: 3000CPU Request: 0.5Memory Request: 512MiMemory Limit: 512MiCreated: just nowLast Updated: just nowStatus: completed means the Satusky platform successfully created all resources. It does not mean your application is healthy — completed is the platform’s record-keeping status. For a live K8s health check use:
1ctl deploy statusStatus: RunningMessage: Deployment is running normallyProgress: 100%Status: Running is fetched live from Kubernetes. If this returns something other than Running, stream your logs immediately to see why.
For scripting — CI pipelines, health checks, URL capture — use the JSON form:
1ctl -o json deploy get{ "deployment_id": "54cf427b-bb70-4977-9ba7-b9b36c20776d", "app_label": "my-api", "status": "completed", "replicas": 1, "cpu_request": "0.5", "memory_request": "512Mi", "memory_limit": "512Mi", "port": 3000, "image": "registry.satusky.com/satusky-container-registry/my-api:abc1234", "domain": "https://braveowl-84ya2ww.satusky.com", "type": "production", "created_at": "2026-04-28T08:30:00+08:00", "updated_at": "2026-04-28T08:30:45+08:00"}The field is domain, not url. To capture the URL in a script:
APP_URL=$(1ctl -o json deploy get | jq -r '.domain')echo $APP_URL# https://braveowl-84ya2ww.satusky.comStep 3: View logs
Section titled “Step 3: View logs”Before setting secrets, confirm the app is running and see what it printed on startup:
1ctl logsExpected output:
Pod Logs────────[2026-04-28 08:30:45] [my-api-54cf89499b-67dfj] server listening on 0.0.0.0:3000[2026-04-28 08:30:47] [2026-04-28 08:30:47] GET /health 200 1ms---Showing last 100 linesEach line is prefixed with [timestamp] [pod-name] followed by whatever your application printed. The pod name (e.g. my-api-54cf89499b-67dfj) is the Kubernetes pod — it changes every time a pod is replaced by a restart or redeploy.
Pass --tail N to limit how many lines are shown:
1ctl logs --tail 20For a live stream (equivalent to tail -f):
1ctl logs streamThe stream stays open until you press Ctrl+C. Use it to watch what happens as requests come in, or to watch a pod restart in real time. logs (without stream) shows stored log history from Loki; logs stream shows live output from the running pod.
If your app is in a crash loop, logs stream will show the crash message repeating with increasing backoff delays. That crash message is what you need — read it carefully before doing anything else.
Step 4: Set secrets
Section titled “Step 4: Set secrets”Your API needs a database connection string and a JWT signing secret. These are sensitive — use 1ctl secret create:
1ctl secret create \ --kv JWT_SECRET=f9a2c7e1b4d83f0629a5e7c1d4b2f809Expected output:
✅ Secret my-api created successfullyWhat happened under the hood: the platform wrote DB_URL and JWT_SECRET into a Kubernetes Secret in your namespace (stored encrypted), and added valueFrom.secretKeyRef entries into the Deployment pod spec so that when the next pod starts it will see these as environment variables. Values are encrypted with AES-256-GCM before storage — they are never returned by any CLI command after this point, including 1ctl secret list.
Secrets do not take effect on the currently running pod. The environment inside a running container is frozen at the moment it started. You need to restart the pod to pick them up — see Step 5.
To confirm the keys were registered (names only, never values):
1ctl secret listNAME SECRET ID DEPLOYMENT ID CREATEDmy-api bcbeb9d7-958c-4aa6-b1a1-51846504cfe8 54cf427b-bb70-4977-9ba7-b9b36c20776d just nowsecret list shows the group — not individual keys. To see which keys are in the group:
1ctl secret list -o json | jq '.[0].key_values[].key'# "DB_URL"# "JWT_SECRET"Updating a secret (rotate one key without touching others)
Section titled “Updating a secret (rotate one key without touching others)”secret create merges. Running it again with a key overwrites only that key and leaves every other key untouched:
1ctl secret create --kv JWT_SECRET=new-signing-key-here✅ Secret my-api created successfullyDB_URL is unchanged. Only JWT_SECRET is overwritten. The Kubernetes Secret is rebuilt from the full merged set — there are no orphaned keys.
Removing a secret key entirely
Section titled “Removing a secret key entirely”1ctl secret unset --key JWT_SECRET✅ Key "JWT_SECRET" removed from secretssecret unset removes the key from both the Kubernetes Secret and the Deployment pod spec. The pod will no longer receive JWT_SECRET as an environment variable on the next start. Any other secrets (DB_URL, etc.) are untouched.
Step 5: Apply secrets with a rolling restart
Section titled “Step 5: Apply secrets with a rolling restart”Secrets (and env vars) take effect on the next pod start. Trigger a rolling restart without rebuilding the image:
1ctl deploy restartExpected output:
💡 Initiating rolling restart for deployment 54cf427b-bb70-4977-9ba7-b9b36c20776d...✅ Rolling restart initiated. Pods are being replaced one by one.💡 Use '1ctl deploy status --deployment-id 54cf427b-bb70-4977-9ba7-b9b36c20776d' to monitor progress.deploy restart triggers a Kubernetes rolling update. Kubernetes starts a new pod with the current Deployment spec (including the secrets you just set), waits for that pod to pass its readiness check, then terminates the old pod. At no point are both pods down simultaneously — traffic is always served.
After a few seconds, confirm the new pod is running:
1ctl deploy statusStatus: RunningMessage: Deployment is running normallyProgress: 100%Then stream logs to verify the app started with the secrets in place:
1ctl logs stream[2026-04-28 08:35:02] [my-api-6c4c54f5d9-4qmns] connected to database in 42ms[2026-04-28 08:35:02] [my-api-6c4c54f5d9-4qmns] server listening on 0.0.0.0:3000Press Ctrl+C to stop the stream.
Step 6: Set environment variables
Section titled “Step 6: Set environment variables”For non-sensitive config — CORS origins, Node environment, log levels — use 1ctl env create. These values are stored in a Kubernetes ConfigMap and are visible in plain text via 1ctl env list.
1ctl env create \ --env CORS_ORIGIN=https://app.mycompany.com \ --env NODE_ENV=productionExpected output:
✅ Environment my-api created successfullyLike secret create, env create merges — running it again with new keys adds those keys without touching the rest. Running it with an existing key overwrites that key’s value.
When to use secrets vs env vars:
- Secrets — passwords, API keys, tokens, anything you would not put in a log file. Values are encrypted at rest and never returned by the CLI after creation.
- Env vars — log levels, feature flags, CORS origins, service URLs,
NODE_ENV. Values are plaintext and visible to anyone with access to the CLI.
Never put credentials in 1ctl env create. Never put NODE_ENV or LOG_LEVEL in 1ctl secret create.
To see what is set:
1ctl env listNAME ENV ID DEPLOYMENT ID CREATEDmy-api 798cf7bc-a85a-45b9-9a91-720e3fcade62 54cf427b-bb70-4977-9ba7-b9b36c20776d just nowTo see the individual keys and values:
1ctl env list -o json | jq '.[0].key_values'[ { "key": "CORS_ORIGIN", "value": "https://app.mycompany.com" }, { "key": "NODE_ENV", "value": "production" }]Apply them with another rolling restart:
1ctl deploy restartOr rebuild and redeploy if you also changed code:
1ctl deploy --waitRemoving an env var key
Section titled “Removing an env var key”1ctl env unset --key CORS_ORIGIN✅ Key "CORS_ORIGIN" removed from environmentenv unset removes the key from both the Kubernetes ConfigMap and the Deployment pod spec, then triggers a rolling update. Pods stay Running throughout — no downtime. NODE_ENV and any other keys are untouched.
Step 7: Redeploy after a code change
Section titled “Step 7: Redeploy after a code change”Fix a bug or ship a feature, then deploy:
git add src/routes/users.jsgit commit -m "fix: scope user list to authenticated org"1ctl deploy --waitExpected output:
💡 Packaging build context...💡 Submitting build to cloud...💡 Build ID: bld_13q7r9m2x5Step 1/5: Building image (cloud) my-api ✓Step 2/5: Creating/updating deployment my-api ✓Step 3/5: Configuring services my-api ✓Step 4/5: Setting up environment and storage my-api ✓Step 5/5: Configuring public routing and dependencies my-api ✓✅ 🚀 Deployment for my-api is successful! Your app is live at: https://braveowl-84ya2ww.satusky.comDeployment ID: 54cf427b-bb70-4977-9ba7-b9b36c20776d💡 Waiting for deployment to become healthy...✅ Deployment is healthy — pods RunningThe URL is the same across all redeploys. The Deployment ID is also the same — it is an identifier for the logical deployment, not the specific build. Only the image tag changes.
Step 8: View release history
Section titled “Step 8: View release history”Every successful 1ctl deploy creates a release entry. You can see the full history and roll back to any previous version:
1ctl deploy releasesVERSION IMAGE STATUS DEPLOYED2 registry.satusky.com/satusky-container-registry/my-api:xyz9 active just now1 registry.satusky.com/satusky-container-registry/my-api:abc1 superseded 1 hour agoStatus values you will see:
active— the version currently deployed and serving trafficsuperseded— replaced by a newer deployrolled_back— this version was active but was reverted withdeploy rollback
deploy restart does not create a new release entry. Only deploy (which builds a new image or upserts the Deployment spec) creates a new version. Restarts are not tracked in the release history.
Step 9: Roll back to a previous version
Section titled “Step 9: Roll back to a previous version”If the latest deploy introduced a regression, roll back instantly — no rebuild required:
1ctl deploy rollback --version 1The CLI prompts for confirmation:
Roll back deployment my-api to version 1? This cannot be undone. [y/N] y✅ Rollback to version 1 initiated💡 Use '1ctl deploy status --deployment-id 54cf427b-bb70-4977-9ba7-b9b36c20776d' to monitor progress.Pass -y to skip the prompt (useful in scripts):
1ctl deploy rollback --version 1 -yWhat happens: Kubernetes immediately replaces the running pod with one using the image from version 1. No rebuild, no upload — it reuses the image already in the registry. The URL stays the same.
Version numbering after rollback: the rollback itself creates a new release entry (e.g. version 3 if you were on version 2), marked active. The version you rolled back from becomes rolled_back. The target version becomes superseded.
Check the new state:
1ctl deploy releasesVERSION IMAGE STATUS DEPLOYED3 registry.satusky.com/satusky-container-registry/my-api:abc1 active just now2 registry.satusky.com/satusky-container-registry/my-api:xyz9 rolled_back 2 minutes ago1 registry.satusky.com/satusky-container-registry/my-api:abc1 superseded 1 hour agoRollback and env vars / secrets: rollback reverts the entire Kubernetes Deployment spec to the snapshot stored at that version — including the env var and secret references. If the version you rolled back to was deployed before you added certain env vars or secrets, those references will be absent in the reverted spec even though the ConfigMap and Secret data still exist. Run 1ctl deploy restart after rollback if you need to re-apply the current env and secret state.
Summary
Section titled “Summary”| Task | Command | When to use |
|---|---|---|
| First deploy | 1ctl deploy --wait | Always on first deploy or when the image changes |
| Check what’s running | 1ctl deploy get | Confirm domain, image, resource spec |
| Live K8s health check | 1ctl deploy status | Diagnose pods that aren’t serving traffic |
| Get URL for scripting | 1ctl -o json deploy get | jq -r '.domain' | CI/CD, smoke tests, automation |
| View stored logs | 1ctl logs | See what the app printed on startup |
| Watch live logs | 1ctl logs stream | Debug crash loops, watch real-time traffic |
| Set secrets (DB creds, signing keys) | 1ctl secret create --kv KEY=VALUE | Before first restart after adding credentials |
| Rotate a single secret | 1ctl secret create --kv KEY=new-value | Key rotation without touching other keys |
| Remove a secret key | 1ctl secret unset --key KEY | Clean up keys that are no longer needed |
| Set env vars (non-sensitive config) | 1ctl env create --env KEY=VALUE | CORS, log levels, feature flags |
| Remove an env var key | 1ctl env unset --key KEY | Clean up keys that are no longer needed |
| Apply env/secret changes | 1ctl deploy restart | After any secret or env var change, no code change |
| Redeploy after code change | 1ctl deploy --wait | New image needed |
| View release history | 1ctl deploy releases | Before deciding whether to roll back |
| Roll back to a previous version | 1ctl deploy rollback --version N -y | Regression introduced by a deploy |
| Tear down completely | 1ctl deploy destroy -y | Remove all K8s resources and DB records |
Next steps
Section titled “Next steps”- Environment Configuration — staging vs production config patterns with separate
satusky.tomlfiles - CI/CD Integration — automate deploys from GitHub Actions with rollback on failure
- Custom Domains — attach
api.mycompany.comto your deployment - Autoscaling — scale on CPU or memory with HPA