Skip to content

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.

You need:

  • 1ctl authenticated to the organization you want to deploy into.
  • Go, Node.js, and Docker are not required locally. SatuSky builds both images remotely.
  • jq and curl for the verification commands.

Confirm your active identity:

Terminal window
1ctl auth status

Run the remaining commands in the same terminal. Create unique names so the generated routes and releases do not collide with another deployment:

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

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:

Terminal window
cat > "$STACK_DIR/api/go.mod" <<'EOF'
module fullstack-api
go 1.24
EOF
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 build
WORKDIR /src
COPY go.mod main.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags='-s -w' -o /api .
FROM scratch
COPY --from=build /api /api
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/api"]
EOF

Create its SatuSky configuration:

Terminal window
cat > "$STACK_DIR/api/satusky.toml" <<EOF
[app]
name = "$API_APP"
port = 8080
cpu_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-"
EOF

FRONTEND_ORIGIN_PREFIX is public routing configuration, not a credential. It is safe in [env].

Deploy the API:

Terminal window
cd "$STACK_DIR/api"
1ctl deploy --wait

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

Terminal window
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 2
done
test -n "${API_URL:-}"
printf 'API_URL=%s\n' "$API_URL"

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.

Terminal window
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-alpine
WORKDIR /app
COPY --chown=node:node package.json server.js ./
USER node
EXPOSE 8080
CMD ["npm", "start"]
EOF

Create the frontend configuration with the API’s generated public URL:

Terminal window
cat > "$STACK_DIR/web/satusky.toml" <<EOF
[app]
name = "$WEB_APP"
port = 8080
cpu_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 --wait

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

Terminal window
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 2
done
test -n "${WEB_URL:-}"
printf 'WEB_URL=%s\n' "$WEB_URL"

New DNS and edge routes can take a short time to become reachable even after the workload is healthy. Poll the public health endpoint:

Terminal window
for attempt in $(seq 1 60); do
curl --max-time 5 --fail --silent "$WEB_URL/health" >/dev/null && break
sleep 2
done
curl --fail --silent "$WEB_URL/" | grep 'Full-stack example'
curl --fail --silent "$API_URL/api/hello"

Simulate the frontend’s browser request:

Terminal window
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-response
grep -F "access-control-allow-origin: $WEB_URL" \
/tmp/satusky-cors-headers
grep -F 'vary: Origin' /tmp/satusky-cors-headers

Confirm that an unrelated origin receives no CORS grant:

Terminal window
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 1
fi

You can also open the actual browser application:

Terminal window
1ctl app open "$WEB_APP"

The page should replace Loading API... with the API JSON response.

Change only the API release:

Terminal window
perl -0pi -e 's/api-v1/api-v2/' "$STACK_DIR/api/main.go"
cd "$STACK_DIR/api"
1ctl deploy --wait

Verify that the API advanced while the frontend did not:

Terminal window
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 2
done
printf '%s\n' "$API_RESPONSE"
curl --fail --silent "$WEB_URL/" | grep 'web-v1'

Change only the frontend release:

Terminal window
perl -0pi -e 's/web-v1/web-v2/' "$STACK_DIR/web/server.js"
cd "$STACK_DIR/web"
1ctl deploy --wait

Verify both final versions:

Terminal window
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 2
done
printf '%s' "$WEB_RESPONSE" | grep 'web-v2'
curl --fail --silent "$API_URL/api/hello" |
grep '"release":"api-v2"'

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.

Delete the frontend first so no browser release points at an API that is already gone:

Terminal window
1ctl app delete "$WEB_APP" --yes
1ctl app delete "$API_APP" --yes

Confirm that both applications are absent:

Terminal window
if 1ctl app list | grep -E "$WEB_APP|$API_APP"; then
echo "Cleanup is still in progress" >&2
exit 1
fi
  • 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/amd64 and linux/arm64 variants.
  • 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.