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.
Prerequisites
Section titled “Prerequisites”1ctlinstalled — see Installation- Authenticated with a valid API token — run
1ctl auth statusand confirm you see your email - A React project with a
npm run buildscript that outputs todist/
Project structure
Section titled “Project structure”my-frontend/├── src/│ └── ...├── dist/ ← built by npm run build├── nginx.conf├── Dockerfile└── satusky.tomlStep 1: Write the nginx config
Section titled “Step 1: Write the nginx config”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.
Step 2: Write the Dockerfile
Section titled “Step 2: Write the Dockerfile”FROM node:20-alpine AS builderWORKDIR /appCOPY package*.json ./RUN npm ciCOPY . .RUN npm run build
FROM nginx:alpineCOPY --from=builder /app/dist /usr/share/nginx/htmlCOPY nginx.conf /etc/nginx/conf.d/default.confEXPOSE 80The 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.
Step 3: Initialize the project
Section titled “Step 3: Initialize the project”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:
1ctl initExpected output:
✅ Created satusky.toml💡 Edit satusky.toml, then run: 1ctl deployOpen 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.
Step 4: Deploy
Section titled “Step 4: Deploy”Satusky builds your image in the cloud. Push your code and run:
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_09m3r7t2q8Step 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.comDeployment ID: df9bf3cb-05ad-4132-8c7d-5ac24817230e💡 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.
- 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.
Step 5: Check status
Section titled “Step 5: Check status”Before moving on, confirm everything looks right:
1ctl deploy getExpected output:
Deployment Details──────────────────Deployment ID: df9bf3cb-05ad-4132-8c7d-5ac24817230eStatus: completedDeployed to machines: c7d2a022-07bf-41f3-b51c-5ebb27365fc4Type: productionRegion:Zone:Version: alpinePort: 80CPU Request: 0.25Memory Request: 128MiMemory Limit: 128MiCreated: 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 Kubernetes health check use:
1ctl deploy statusStatus: RunningMessage: Deployment is running normallyProgress: 100%Status: Running is fetched live from Kubernetes. If you see anything other than Running, stream your logs immediately to diagnose the problem:
1ctl logs streamStep 6: View logs
Section titled “Step 6: View logs”nginx access logs confirm requests are landing and client-side routing is working:
1ctl logs --tail 5Expected 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 linesEach 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):
1ctl logs streamThe stream stays open until you press Ctrl+C. Use it to watch requests arrive in real time or to diagnose a crash loop.
Step 7: Set environment variables
Section titled “Step 7: Set environment variables”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.
1ctl env create --env CACHE_CONTROL=max-age=31536000 --env ENV=productionExpected output:
✅ Environment my-frontend created successfullyenv 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:
1ctl env listNAME ENV ID DEPLOYMENT ID CREATEDmy-frontend 798cf7bc-a85a-45b9-9a91-720e3fcade62 df9bf3cb-05ad-4132-8c7d-5ac24817230e just nowTo see individual keys and values:
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:
1ctl deploy restartExpected 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:
kubectl -n <your-namespace> get pods -l app=my-frontendNAME READY STATUS RESTARTS AGEmy-frontend-6b4c557b7b-cqj6h 0/1 ContainerCreating 0 3smy-frontend-b879959f4-576mt 1/1 Running 0 11sAfter a few seconds, confirm only the new pod is running:
1ctl deploy statusStatus: RunningMessage: Deployment is running normallyProgress: 100%Removing an env var key
Section titled “Removing an env var key”1ctl env unset --key CACHE_CONTROLExpected output:
✅ Key "CACHE_CONTROL" removed from environmentenv 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.
Step 9: Redeploy after a code change
Section titled “Step 9: Redeploy after a code change”Fix a bug or ship a feature, then redeploy:
git add src/git commit -m "fix: update routing config"1ctl deploy --waitThe 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.
Step 10: View release history
Section titled “Step 10: 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 releasesExpected output:
VERSION IMAGE STATUS DEPLOYED─────── ───────────────────────────────────────────────────────────────────── ────────── ────────2 registry.satusky.com/satusky-container-registry/my-frontend:xyz9abc active just now1 registry.satusky.com/satusky-container-registry/my-frontend:abc1xyz 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) creates a new version. Restarts are tracked in Kubernetes but not in the Satusky release history.
Step 11: Roll back to a previous version
Section titled “Step 11: 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-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:
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.
Step 12: Add a custom domain with SSL
Section titled “Step 12: Add a custom domain with SSL”Requires production cluster: This step requires cert-manager and Cloudflare DNS configured in your cluster. It will not work in a local development environment.
1ctl deploy --domain app.mycompany.comSatusky 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:
curl -I https://app.mycompany.comHTTP/2 200server: nginxcontent-type: text/htmlStep 13: Tear down
Section titled “Step 13: Tear down”When you are done, remove all Kubernetes resources and database records:
1ctl deploy destroy -yExpected output:
💡 Destroying deployment df9bf3cb-05ad-4132-8c7d-5ac24817230e...✅ Deployment df9bf3cb-05ad-4132-8c7d-5ac24817230e destroyed successfullyThis deletes the deployment-owned workload resources. Verify public route cleanup explicitly:
kubectl -n <namespace> get deploy,svc,httproute,ingress,pvc1ctl domains listIf 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.
Troubleshooting
Section titled “Troubleshooting”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.
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 Kubernetes health check | 1ctl deploy status | Diagnose pods that aren’t serving traffic |
| View stored logs | 1ctl logs | See nginx startup and access logs |
| Watch live logs | 1ctl logs stream | Debug crash loops, watch real-time requests |
| Set env vars (non-sensitive) | 1ctl env create --env KEY=VALUE | Cache headers, feature flags, runtime config |
| Remove an env var | 1ctl env unset --key KEY | Clean up keys no longer needed |
| Apply env var changes | 1ctl deploy restart | After any 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 Kubernetes resources and DB records |
Next steps
Section titled “Next steps”- Custom Domains — full DNS setup walkthrough
- CI/CD Integration — automate deploys on every push to
main - Environment Configuration — staging vs production deployments
- Autoscaling — scale on CPU or memory with HPA