Skip to content

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.

  • 1ctl installed — see Installation
  • Authenticated — run 1ctl auth status and confirm you see your email
  • A Neon.tech project with a connection string ready
my-api/
├── src/
│ ├── index.js ← entry point, Express app
│ ├── routes/
│ │ ├── auth.js
│ │ └── users.js
│ └── middleware/
│ └── jwt.js
├── Dockerfile
├── package.json
├── package-lock.json
└── satusky.toml

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 builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ---- runtime ----
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY src ./src
COPY package.json ./
EXPOSE 3000
CMD ["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}`);
});

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:

Terminal window
1ctl init
✅ Created satusky.toml
💡 Edit satusky.toml, then run: 1ctl deploy

init 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.

Terminal window
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_09p4x2k7n1
Step 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.com
Deployment ID: 54cf427b-bb70-4977-9ba7-b9b36c20776d
💡 Waiting for deployment to become healthy...
✅ Deployment is healthy — pods Running

What 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 --image with 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.

Before moving on, confirm everything looks right:

Terminal window
1ctl deploy get

Expected output:

Deployment Details
──────────────────
Deployment ID: 54cf427b-bb70-4977-9ba7-b9b36c20776d
Status: completed
URL: https://braveowl-84ya2ww.satusky.com
Deployed to machines: c7d2a022-07bf-41f3-b51c-5ebb27365fc4
Type: production
Version: alpine
Port: 3000
CPU Request: 0.5
Memory Request: 512Mi
Memory Limit: 512Mi
Created: just now
Last Updated: just now

Status: 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:

Terminal window
1ctl deploy status
Status: Running
Message: Deployment is running normally
Progress: 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:

Terminal window
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:

Terminal window
APP_URL=$(1ctl -o json deploy get | jq -r '.domain')
echo $APP_URL
# https://braveowl-84ya2ww.satusky.com

Before setting secrets, confirm the app is running and see what it printed on startup:

Terminal window
1ctl logs

Expected 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 lines

Each 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:

Terminal window
1ctl logs --tail 20

For a live stream (equivalent to tail -f):

Terminal window
1ctl logs stream

The 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.

Your API needs a database connection string and a JWT signing secret. These are sensitive — use 1ctl secret create:

Terminal window
1ctl secret create \
--kv DB_URL=postgresql://neonuser:[email protected]/mydb?sslmode=require \
--kv JWT_SECRET=f9a2c7e1b4d83f0629a5e7c1d4b2f809

Expected output:

✅ Secret my-api created successfully

What 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):

Terminal window
1ctl secret list
NAME SECRET ID DEPLOYMENT ID CREATED
my-api bcbeb9d7-958c-4aa6-b1a1-51846504cfe8 54cf427b-bb70-4977-9ba7-b9b36c20776d just now

secret list shows the group — not individual keys. To see which keys are in the group:

Terminal window
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:

Terminal window
1ctl secret create --kv JWT_SECRET=new-signing-key-here
✅ Secret my-api created successfully

DB_URL is unchanged. Only JWT_SECRET is overwritten. The Kubernetes Secret is rebuilt from the full merged set — there are no orphaned keys.

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

secret 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:

Terminal window
1ctl deploy restart

Expected 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:

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

Then stream logs to verify the app started with the secrets in place:

Terminal window
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:3000

Press Ctrl+C to stop the stream.

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.

Terminal window
1ctl env create \
--env CORS_ORIGIN=https://app.mycompany.com \
--env NODE_ENV=production

Expected output:

✅ Environment my-api created successfully

Like 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:

Terminal window
1ctl env list
NAME ENV ID DEPLOYMENT ID CREATED
my-api 798cf7bc-a85a-45b9-9a91-720e3fcade62 54cf427b-bb70-4977-9ba7-b9b36c20776d just now

To see the individual keys and values:

Terminal window
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:

Terminal window
1ctl deploy restart

Or rebuild and redeploy if you also changed code:

Terminal window
1ctl deploy --wait
Terminal window
1ctl env unset --key CORS_ORIGIN
✅ Key "CORS_ORIGIN" removed from environment

env 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.

Fix a bug or ship a feature, then deploy:

Terminal window
git add src/routes/users.js
git commit -m "fix: scope user list to authenticated org"
1ctl deploy --wait

Expected output:

💡 Packaging build context...
💡 Submitting build to cloud...
💡 Build ID: bld_13q7r9m2x5
Step 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.com
Deployment ID: 54cf427b-bb70-4977-9ba7-b9b36c20776d
💡 Waiting for deployment to become healthy...
✅ Deployment is healthy — pods Running

The 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.

Every successful 1ctl deploy creates a release entry. You can see the full history and roll back to any previous version:

Terminal window
1ctl deploy releases
VERSION IMAGE STATUS DEPLOYED
2 registry.satusky.com/satusky-container-registry/my-api:xyz9 active just now
1 registry.satusky.com/satusky-container-registry/my-api:abc1 superseded 1 hour ago

Status values you will see:

  • active — the version currently deployed and serving traffic
  • superseded — replaced by a newer deploy
  • rolled_back — this version was active but was reverted with deploy 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.

If the latest deploy introduced a regression, roll back instantly — no rebuild required:

Terminal window
1ctl deploy rollback --version 1

The 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):

Terminal window
1ctl deploy rollback --version 1 -y

What 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:

Terminal window
1ctl deploy releases
VERSION IMAGE STATUS DEPLOYED
3 registry.satusky.com/satusky-container-registry/my-api:abc1 active just now
2 registry.satusky.com/satusky-container-registry/my-api:xyz9 rolled_back 2 minutes ago
1 registry.satusky.com/satusky-container-registry/my-api:abc1 superseded 1 hour ago

Rollback 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.

TaskCommandWhen to use
First deploy1ctl deploy --waitAlways on first deploy or when the image changes
Check what’s running1ctl deploy getConfirm domain, image, resource spec
Live K8s health check1ctl deploy statusDiagnose pods that aren’t serving traffic
Get URL for scripting1ctl -o json deploy get | jq -r '.domain'CI/CD, smoke tests, automation
View stored logs1ctl logsSee what the app printed on startup
Watch live logs1ctl logs streamDebug crash loops, watch real-time traffic
Set secrets (DB creds, signing keys)1ctl secret create --kv KEY=VALUEBefore first restart after adding credentials
Rotate a single secret1ctl secret create --kv KEY=new-valueKey rotation without touching other keys
Remove a secret key1ctl secret unset --key KEYClean up keys that are no longer needed
Set env vars (non-sensitive config)1ctl env create --env KEY=VALUECORS, log levels, feature flags
Remove an env var key1ctl env unset --key KEYClean up keys that are no longer needed
Apply env/secret changes1ctl deploy restartAfter any secret or env var change, no code change
Redeploy after code change1ctl deploy --waitNew image needed
View release history1ctl deploy releasesBefore deciding whether to roll back
Roll back to a previous version1ctl deploy rollback --version N -yRegression introduced by a deploy
Tear down completely1ctl deploy destroy -yRemove all K8s resources and DB records