Skip to content

Deploy a Frontend

This guide deploys a React single-page application to Satusky. The same approach works for Vue, Svelte, or any framework that produces a dist/ folder. nginx serves the built assets, handles client-side routing, and Satusky puts HTTPS in front of it automatically.

Every step shows the exact output you should see so you always know whether things went right.

  • 1ctl installed — see Installation
  • Authenticated with a valid API token — run 1ctl auth status and confirm you see your email
  • A React project with a npm run build script that outputs to dist/
my-frontend/
├── src/
│ └── ...
├── dist/ ← built by npm run build
├── nginx.conf
├── Dockerfile
└── satusky.toml

Create nginx.conf in your project root. This config handles client-side routing by falling back to index.html for any path that isn’t a real file — the standard requirement for React Router, TanStack Router, and similar.

server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

The try_files directive is the critical line. Without it, navigating directly to /dashboard/settings returns a 404 because nginx looks for a file at that path. With it, nginx falls through to index.html and lets the JavaScript router take over.

For production with aggressive caching, add separate location blocks for hashed assets and HTML:

server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}

Bundlers like Vite produce filenames with content hashes (e.g. index-c3k9j.js). These can be cached aggressively because the filename changes on every rebuild. index.html must never be cached because it references the hashed filenames — without no-cache, browsers see stale HTML after a redeploy and keep loading the old assets.

FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

The two-stage build keeps the final image lean. Only nginx and the compiled static files ship — no Node.js runtime, no node_modules. Satusky builds this image in the cloud using Kaniko, so you do not need Docker installed locally.

Run 1ctl init in your project directory. The CLI creates satusky.toml with the directory name as the app name and 8080 as the default port:

Terminal window
1ctl init

Expected output:

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

Open satusky.toml and update it. Set the port to 80 to match nginx and lower the resource limits — a static file server needs far less than the defaults:

[app]
name = "my-frontend"
port = 80
dockerfile = "Dockerfile"
cpu = "0.25"
memory = "128Mi"

All fields go under the single [app] section. There are no [build], [resources], or [network] sections. The name field becomes the Kubernetes app label and must be unique within your namespace. Commit this file — it contains no secrets.

Why port = 80? Satusky reads the port from satusky.toml and uses it as the container port in the Kubernetes Deployment and the ClusterIP Service target. nginx listens on port 80 by default, so the port here must match. If they differ, Satusky creates the Service pointing at the wrong port and all requests return 502.

Satusky builds your image in the cloud. Push your code and run:

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_09m3r7t2q8
Step 1/5: Building image (cloud) my-frontend ✓
Step 2/5: Creating/updating deployment my-frontend ✓
Step 3/5: Configuring services my-frontend ✓
Step 4/5: Setting up environment and storage my-frontend ✓
Step 5/5: Configuring public routing and dependencies my-frontend ✓
💡 Generated new domain: adjective-animal-a1b2c3d.satusky.com
✅ 🚀 Deployment for my-frontend is successful! Your app is live at: https://adjective-animal-a1b2c3d.satusky.com
Deployment ID: df9bf3cb-05ad-4132-8c7d-5ac24817230e
💡 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.
  • Steps 2–3 — creates or updates the Kubernetes Deployment and ClusterIP Service in your namespace.
  • Step 4 — wires up any env vars you have already set (none yet on a first deploy).
  • Step 5 — creates the public route and assigns a randomly generated domain.

The URL is a randomly generated subdomain — something like adjective-animal-a1b2c3d.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.

Open the URL in a browser and test that deep links work: navigate directly to /about or /dashboard. They should load your app, not return 404.

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: df9bf3cb-05ad-4132-8c7d-5ac24817230e
Status: completed
Deployed to machines: c7d2a022-07bf-41f3-b51c-5ebb27365fc4
Type: production
Region:
Zone:
Version: alpine
Port: 80
CPU Request: 0.25
Memory Request: 128Mi
Memory Limit: 128Mi
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 Kubernetes 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 you see anything other than Running, stream your logs immediately to diagnose the problem:

Terminal window
1ctl logs stream

nginx access logs confirm requests are landing and client-side routing is working:

Terminal window
1ctl logs --tail 5

Expected output:

Pod Logs
────────
[2026-04-28 08:54:49] [my-frontend-ff8bcbf57-6wf9s] 2026/04/28 00:54:48 [notice] 1#1: start worker process 41
[2026-04-28 08:54:49] [my-frontend-ff8bcbf57-6wf9s] 2026/04/28 00:54:48 [notice] 1#1: start worker process 40
[2026-04-28 08:54:49] [my-frontend-ff8bcbf57-6wf9s] 2026/04/28 00:54:48 [notice] 1#1: start worker process 39
[2026-04-28 08:54:49] [my-frontend-ff8bcbf57-6wf9s] 2026/04/28 00:54:48 [notice] 1#1: start worker process 38
[2026-04-28 08:54:49] [my-frontend-ff8bcbf57-6wf9s] 2026/04/28 00:54:48 [notice] 1#1: start worker process 37
---
Showing last 5 lines

Each line is prefixed with [timestamp] [pod-name] followed by whatever nginx printed. The pod name (e.g. my-frontend-ff8bcbf57-6wf9s) changes every time a pod is replaced by a restart or redeploy.

Once requests come in, you will see access log entries like:

[2026-04-28 09:01:02] [my-frontend-ff8bcbf57-6wf9s] 10.0.0.1 - - [28/Apr/2026:01:01:02 +0000] "GET / HTTP/1.1" 200 615 "-" "Mozilla/5.0..."
[2026-04-28 09:01:03] [my-frontend-ff8bcbf57-6wf9s] 10.0.0.1 - - [28/Apr/2026:01:01:03 +0000] "GET /dashboard HTTP/1.1" 200 615 "-" "Mozilla/5.0..."

The GET /dashboard returning 200 confirms try_files is working — nginx served index.html instead of returning a 404.

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 requests arrive in real time or to diagnose a crash loop.

For non-sensitive config — cache settings, environment flags — use 1ctl env create. These values are stored in a Kubernetes ConfigMap and are visible in plain text. Do not use env create for passwords or API keys — use 1ctl secret create for those.

Terminal window
1ctl env create --env CACHE_CONTROL=max-age=31536000 --env ENV=production

Expected output:

✅ Environment my-frontend created successfully

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.

What happened under the hood: Satusky wrote your keys into a Kubernetes ConfigMap named my-frontend-environments and added valueFrom.configMapKeyRef entries into the Deployment pod spec so the next pod start receives these as environment variables.

Env vars 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 8.

To see what is currently set:

Terminal window
1ctl env list
NAME ENV ID DEPLOYMENT ID CREATED
my-frontend 798cf7bc-a85a-45b9-9a91-720e3fcade62 df9bf3cb-05ad-4132-8c7d-5ac24817230e just now

To see individual keys and values:

Terminal window
1ctl env list -o json | jq '.[0].key_values'
[
{ "key": "CACHE_CONTROL", "value": "max-age=31536000" },
{ "key": "ENV", "value": "production" }
]

Build-time vs runtime env vars. Frontend env vars like VITE_API_URL are baked in at build time, not injected at runtime. They must be passed as Docker ARG/ENV pairs in the Dockerfile or set in your CI pipeline before building. They do not go through 1ctl env create. Use 1ctl env create only for values that your server-side process (e.g. nginx, a Node SSR layer) reads at runtime.

Step 8: Apply env vars with a rolling restart

Section titled “Step 8: Apply env vars with a rolling restart”

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 df9bf3cb-05ad-4132-8c7d-5ac24817230e...
✅ Rolling restart initiated. Pods are being replaced one by one.
💡 Use '1ctl deploy status --deployment-id df9bf3cb-05ad-4132-8c7d-5ac24817230e' to monitor progress.

deploy restart triggers a Kubernetes rolling update. Kubernetes starts a new pod with the updated Deployment spec, waits for it to pass its readiness check, then terminates the old pod. At no point are both pods down simultaneously — traffic is always served.

During the rollover you will briefly see two pods:

Terminal window
kubectl -n <your-namespace> get pods -l app=my-frontend
NAME READY STATUS RESTARTS AGE
my-frontend-6b4c557b7b-cqj6h 0/1 ContainerCreating 0 3s
my-frontend-b879959f4-576mt 1/1 Running 0 11s

After a few seconds, confirm only the new pod is running:

Terminal window
1ctl deploy status
Status: Running
Message: Deployment is running normally
Progress: 100%
Terminal window
1ctl env unset --key CACHE_CONTROL

Expected output:

✅ Key "CACHE_CONTROL" removed from environment

env unset removes the key from both the Kubernetes ConfigMap and the Deployment pod spec, then triggers a rolling update automatically. Pods stay Running throughout — no downtime. Any other keys (ENV, etc.) are untouched.

Fix a bug or ship a feature, then redeploy:

Terminal window
git add src/
git commit -m "fix: update routing config"
1ctl deploy --wait

The URL is the same across all redeploys. Only the image tag changes. If you changed only env vars (no code), use 1ctl deploy restart instead — it avoids the cloud build step and is much faster.

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

Expected output:

VERSION IMAGE STATUS DEPLOYED
─────── ───────────────────────────────────────────────────────────────────── ────────── ────────
2 registry.satusky.com/satusky-container-registry/my-frontend:xyz9abc active just now
1 registry.satusky.com/satusky-container-registry/my-frontend:abc1xyz 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) creates a new version. Restarts are tracked in Kubernetes but not in the Satusky 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-frontend to version 1? This cannot be undone. [y/N] y
✅ Rollback to version 1 initiated
💡 Use '1ctl deploy status --deployment-id df9bf3cb-05ad-4132-8c7d-5ac24817230e' to monitor progress.

Pass -y to skip the prompt:

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.

Requires production cluster: This step requires cert-manager and Cloudflare DNS configured in your cluster. It will not work in a local development environment.

Terminal window
1ctl deploy --domain app.mycompany.com

Satusky updates the public route and cert-manager provisions a Let’s Encrypt certificate. HTTPS is live within about 60 seconds of DNS propagating. See the Custom Domains guide for the required DNS records.

Confirm the cert is valid after DNS propagates:

Terminal window
curl -I https://app.mycompany.com
HTTP/2 200
server: nginx
content-type: text/html

When you are done, remove all Kubernetes resources and database records:

Terminal window
1ctl deploy destroy -y

Expected output:

💡 Destroying deployment df9bf3cb-05ad-4132-8c7d-5ac24817230e...
✅ Deployment df9bf3cb-05ad-4132-8c7d-5ac24817230e destroyed successfully

This deletes the deployment-owned workload resources. Verify public route cleanup explicitly:

Terminal window
kubectl -n <namespace> get deploy,svc,httproute,ingress,pvc
1ctl domains list

If you redeploy with the same app name, Satusky may generate a new platform subdomain. The backend and Kubernetes route should converge on the same hostname; if they do not, treat it as route drift.

Deep links return 404. The try_files directive is missing or wrong in your nginx.conf. Check that try_files $uri $uri/ /index.html; is present in the location / block.

Blank page after deploy. The dist/ folder wasn’t built before the Dockerfile copied it. Make sure your Dockerfile runs npm run build as part of the image build, or run npm run build locally before deploying with a pre-built image.

502 Bad Gateway. The port in satusky.toml doesn’t match what nginx is actually listening on. Nginx listens on 80 by default; confirm your satusky.toml has port = 80.

App shows old version after redeploy. Your browser may be serving cached index.html. Hard-refresh with Ctrl+Shift+R (or Cmd+Shift+R on macOS). To prevent this for all users, add add_header Cache-Control "no-cache"; to the location / block in your nginx config.

--wait timed out. The deployment was still created. Run 1ctl logs stream immediately to see why the pod isn’t becoming healthy. Common causes: wrong port, Dockerfile build failed, image pull error.

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 Kubernetes health check1ctl deploy statusDiagnose pods that aren’t serving traffic
View stored logs1ctl logsSee nginx startup and access logs
Watch live logs1ctl logs streamDebug crash loops, watch real-time requests
Set env vars (non-sensitive)1ctl env create --env KEY=VALUECache headers, feature flags, runtime config
Remove an env var1ctl env unset --key KEYClean up keys no longer needed
Apply env var changes1ctl deploy restartAfter any 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 Kubernetes resources and DB records