Deploy a full-stack application
This guide deploys two applications:
- A Go API with a generated HTTPS URL.
- A Node.js frontend that receives that API URL as public runtime configuration.
The applications are separate releases. You can update or roll back one without rebuilding the other.
This guide focuses on the connection between them. For framework-specific build advice, see the dedicated frontend and backend guides.
Before you start
Section titled “Before you start”You need:
1ctlauthenticated to the organization you want to deploy into.- Go, Node.js, and Docker are not required locally. SatuSky builds both images remotely.
jqandcurlfor the verification commands.
Confirm your active identity:
1ctl auth statusRun the remaining commands in the same terminal. Create unique names so the generated routes and releases do not collide with another deployment:
export STACK_ID="$(date +%m%d%H%M)"export API_APP="fullstack-api-$STACK_ID"export WEB_APP="fullstack-web-$STACK_ID"export STACK_DIR="$PWD/satusky-fullstack-$STACK_ID"
mkdir -p "$STACK_DIR/api" "$STACK_DIR/web"1. Create the API
Section titled “1. Create the API”The API grants browser access only when the request origin:
- Starts with this frontend application’s unique generated-host prefix.
- Ends in
.satusky.com.
It returns the requesting origin, not *. An unrelated site does not receive a CORS grant.
Create the API:
cat > "$STACK_DIR/api/go.mod" <<'EOF'module fullstack-api
go 1.24EOF
cat > "$STACK_DIR/api/main.go" <<'EOF'package main
import ( "encoding/json" "log" "net/http" "os" "strings")
func allowedOrigin(origin string) bool { prefix := os.Getenv("FRONTEND_ORIGIN_PREFIX") return strings.HasPrefix(origin, prefix) && strings.HasSuffix(origin, ".satusky.com")}
func main() { release := "api-v1" mux := http.NewServeMux()
mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) })
mux.HandleFunc("/api/hello", func(w http.ResponseWriter, r *http.Request) { if origin := r.Header.Get("Origin"); allowedOrigin(origin) { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") }
w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]string{ "message": "hello from SatuSky", "release": release, }) })
log.Fatal(http.ListenAndServe(":8080", mux))}EOF
cat > "$STACK_DIR/api/Dockerfile" <<'EOF'FROM golang:1.24-alpine AS buildWORKDIR /srcCOPY go.mod main.go ./RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /api .
FROM scratchCOPY --from=build /api /apiUSER 65532:65532EXPOSE 8080ENTRYPOINT ["/api"]EOFCreate its SatuSky configuration:
cat > "$STACK_DIR/api/satusky.toml" <<EOF[app]name = "$API_APP"port = 8080cpu_request = "10m"cpu_limit = "50m"memory = "32Mi"
[build]dockerfile = "Dockerfile"fast_build = true
[checks]health_path = "/health"
[checks.readiness]period_seconds = 5[checks.readiness.http_get]path = "/health"port = 8080
[deploy]strategy = "rolling"rolling_max_surge = "25%"rolling_max_unavailable = "0"
[env]FRONTEND_ORIGIN_PREFIX = "https://$WEB_APP-"EOFFRONTEND_ORIGIN_PREFIX is public routing configuration, not a credential. It is safe in [env].
Deploy the API:
cd "$STACK_DIR/api"1ctl deploy --waitFast cloud builds produce both linux/amd64 and linux/arm64 images. --wait waits for the accepted deployment to become healthy.
Read the generated HTTPS URL. Route publication can finish shortly after the workload becomes healthy, so poll instead of assuming it is immediate:
for attempt in $(seq 1 60); do export API_URL="$( 1ctl -o json app get "$API_APP" 2>/dev/null | jq -r '.domain // empty' )" test -n "$API_URL" && break sleep 2done
test -n "${API_URL:-}"printf 'API_URL=%s\n' "$API_URL"2. Create the frontend
Section titled “2. Create the frontend”The frontend reads PUBLIC_API_URL when its process starts and writes that URL into the HTML response. It does not put credentials into a browser bundle.
cat > "$STACK_DIR/web/package.json" <<'EOF'{"scripts":{"start":"node server.js"}}EOF
cat > "$STACK_DIR/web/server.js" <<'EOF'const http = require("http");
const api = process.env.PUBLIC_API_URL;const release = "web-v1";const html = `<!doctype html><html lang="en"> <head><meta charset="utf-8"><title>Full-stack example</title></head> <body> <main> <h1>Full-stack example</h1> <p id="release">${release}</p> <pre id="result">Loading API...</pre> </main> <script> fetch(${JSON.stringify(api + "/api/hello")}) .then((response) => response.json()) .then((value) => { document.querySelector("#result").textContent = JSON.stringify(value); }) .catch((error) => { document.querySelector("#result").textContent = error.message; }); </script> </body></html>`;
http.createServer((request, response) => { if (request.url === "/health") { response.writeHead(200); response.end("ok"); return; }
response.writeHead(200, {"content-type": "text/html; charset=utf-8"}); response.end(html);}).listen(8080);EOF
cat > "$STACK_DIR/web/Dockerfile" <<'EOF'FROM node:24-alpineWORKDIR /appCOPY --chown=node:node package.json server.js ./USER nodeEXPOSE 8080CMD ["npm", "start"]EOFCreate the frontend configuration with the API’s generated public URL:
cat > "$STACK_DIR/web/satusky.toml" <<EOF[app]name = "$WEB_APP"port = 8080cpu_request = "10m"cpu_limit = "50m"memory = "32Mi"
[build]dockerfile = "Dockerfile"fast_build = true
[checks]health_path = "/health"
[checks.readiness]period_seconds = 5[checks.readiness.http_get]path = "/health"port = 8080
[deploy]strategy = "rolling"rolling_max_surge = "25%"rolling_max_unavailable = "0"
[env]PUBLIC_API_URL = "$API_URL"EOF
cd "$STACK_DIR/web"1ctl deploy --waitPUBLIC_API_URL is intentionally public. Database URLs, API tokens, signing keys, and other credentials must never be included in frontend [env], source files, or image build arguments. Keep secrets in the backend and manage them with 1ctl secret.
Read the frontend URL:
for attempt in $(seq 1 60); do export WEB_URL="$( 1ctl -o json app get "$WEB_APP" 2>/dev/null | jq -r '.domain // empty' )" test -n "$WEB_URL" && break sleep 2done
test -n "${WEB_URL:-}"printf 'WEB_URL=%s\n' "$WEB_URL"3. Verify the browser path and CORS
Section titled “3. Verify the browser path and CORS”New DNS and edge routes can take a short time to become reachable even after the workload is healthy. Poll the public health endpoint:
for attempt in $(seq 1 60); do curl --max-time 5 --fail --silent "$WEB_URL/health" >/dev/null && break sleep 2done
curl --fail --silent "$WEB_URL/" | grep 'Full-stack example'curl --fail --silent "$API_URL/api/hello"Simulate the frontend’s browser request:
curl --fail --silent \ --dump-header /tmp/satusky-cors-headers \ --output /tmp/satusky-api-response \ --header "Origin: $WEB_URL" \ "$API_URL/api/hello"
cat /tmp/satusky-api-responsegrep -F "access-control-allow-origin: $WEB_URL" \ /tmp/satusky-cors-headersgrep -F 'vary: Origin' /tmp/satusky-cors-headersConfirm that an unrelated origin receives no CORS grant:
curl --fail --silent \ --dump-header /tmp/satusky-untrusted-headers \ --output /dev/null \ --header 'Origin: https://attacker.example' \ "$API_URL/api/hello"
if grep -qi '^access-control-allow-origin:' \ /tmp/satusky-untrusted-headers; then echo "Unexpected CORS grant" >&2 exit 1fiYou can also open the actual browser application:
1ctl app open "$WEB_APP"The page should replace Loading API... with the API JSON response.
4. Roll the API independently
Section titled “4. Roll the API independently”Change only the API release:
perl -0pi -e 's/api-v1/api-v2/' "$STACK_DIR/api/main.go"
cd "$STACK_DIR/api"1ctl deploy --waitVerify that the API advanced while the frontend did not:
for attempt in $(seq 1 60); do API_RESPONSE="$(curl --max-time 5 --fail --silent \ "$API_URL/api/hello" 2>/dev/null || true)" printf '%s' "$API_RESPONSE" | grep -q '"release":"api-v2"' && break sleep 2done
printf '%s\n' "$API_RESPONSE"curl --fail --silent "$WEB_URL/" | grep 'web-v1'5. Roll the frontend independently
Section titled “5. Roll the frontend independently”Change only the frontend release:
perl -0pi -e 's/web-v1/web-v2/' "$STACK_DIR/web/server.js"
cd "$STACK_DIR/web"1ctl deploy --waitVerify both final versions:
for attempt in $(seq 1 60); do WEB_RESPONSE="$(curl --max-time 5 --fail --silent \ "$WEB_URL/" 2>/dev/null || true)" printf '%s' "$WEB_RESPONSE" | grep -q 'web-v2' && break sleep 2done
printf '%s' "$WEB_RESPONSE" | grep 'web-v2'curl --fail --silent "$API_URL/api/hello" | grep '"release":"api-v2"'Choose a rollout strategy deliberately
Section titled “Choose a rollout strategy deliberately”This example uses a rolling update with no unavailable replicas. The scheduler needs enough spare CPU and memory for a surge pod.
For a single constrained machine, use:
[deploy]strategy = "recreate"recreate avoids surge capacity, but the application can be briefly unavailable while the old process stops and the new one starts.
6. Clean up
Section titled “6. Clean up”Delete the frontend first so no browser release points at an API that is already gone:
1ctl app delete "$WEB_APP" --yes1ctl app delete "$API_APP" --yesConfirm that both applications are absent:
if 1ctl app list | grep -E "$WEB_APP|$API_APP"; then echo "Cleanup is still in progress" >&2 exit 1fiWhat this architecture gives you
Section titled “What this architecture gives you”- Independent releases: API and frontend builds have separate deployment IDs and release histories.
- Explicit public configuration: The frontend receives only the API’s public HTTPS URL.
- Narrow CORS: The API grants the generated hostname for this frontend name, not every website.
- Platform routing: Each application receives its own managed HTTPS route.
- Portable images: Fast builds publish
linux/amd64andlinux/arm64variants. - Failure isolation: A frontend rollback does not roll back the API, and an API rollout does not rebuild the frontend.
For authenticated applications, keep session or token validation in the API. CORS controls which browsers may read a response; it is not authentication or authorization.