Skip to content

API with a Database

Most backend services need a database. This guide walks through the full lifecycle: deploy the API, attach a DATABASE_URL secret, tune the connection pool with non-sensitive env vars, verify health, and rotate the password when the time comes.

The pattern applies equally to Python, Node.js, or any language — only the Go snippet and Dockerfile change.

Satusky injects both secrets and environment variables into your containers as ordinary environment variables at pod start. The difference is in how they’re stored and who can read them:

  • Secrets (1ctl secret) — encrypted at rest with AES-256-GCM, values never returned after creation. Use these for DATABASE_URL, API keys, and tokens.
  • Env vars (1ctl env) — stored in plaintext ConfigMaps. Use these for tuning knobs: pool sizes, timeouts, log levels.

Neither type hot-reloads into running pods. Changes take effect on the next pod start — either from a 1ctl deploy restart or a fresh 1ctl deploy.

The API reads DATABASE_URL from its environment and refuses to start if it isn’t set:

package main
import (
"database/sql"
"log"
"net/http"
"os"
_ "github.com/lib/pq"
)
func main() {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
log.Fatal("FATAL: DATABASE_URL is not set")
}
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatalf("FATAL: could not open database: %v", err)
}
defer db.Close()
if err := db.Ping(); err != nil {
log.Fatalf("FATAL: could not connect to database: %v", err)
}
log.Println("connected to database")
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
log.Println("server listening on :8080")
log.Fatal(http.ListenAndServe(":8080", nil))
}

The log.Fatal on a missing DATABASE_URL is intentional. A misconfigured pod that crashes immediately is much easier to diagnose than one that silently uses a nil connection and produces confusing errors later.

FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o server .
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
CMD ["./server"]

The multi-stage build keeps the final image small. ca-certificates lets the binary verify TLS when connecting to managed databases that require SSL. tzdata covers any code that formats timestamps in a named timezone.

[app]
name = "go-api"
port = 8080
dockerfile = "Dockerfile"
cpu = "0.5"
memory = "256Mi"

Commit this file. It contains no secrets — only structural deployment spec.

Step 1: Deploy first (it will crash — that’s expected)

Section titled “Step 1: Deploy first (it will crash — that’s expected)”
Terminal window
1ctl deploy --wait

Expected output:

💡 Packaging build context...
💡 Submitting build to cloud...
💡 Build ID: bld_4m9rk2x7q1
Step 1/5: Building image (cloud) go-api ✓
Step 2/5: Creating/updating deployment go-api ✓
Step 3/5: Configuring services go-api ✓
Step 4/5: Setting up environment and storage go-api ✓
Step 5/5: Configuring public routing and dependencies go-api ✓
💡 Generated new domain: quietpanda-r7w4p1.satusky.com
✅ 🚀 Deployment for go-api is successful! Your app is live at: https://quietpanda-r7w4p1.satusky.com
Deployment ID: 4m9rk2x7-5f87-4612-b306-3da846b95d18
💡 Waiting for deployment to become healthy...
✅ Deployment is healthy — pods Running

--wait blocks until the pod reaches Running status — which only means the container started, not that the application is healthy. The pod will be running and immediately crashing in a restart loop because DATABASE_URL is missing. That’s expected. The next steps fix it.

Terminal window
1ctl logs stream

Expected output:

2026-04-27T10:00:08Z [go-api] FATAL: DATABASE_URL is not set
2026-04-27T10:00:08Z [go-api] exit status 1
2026-04-27T10:00:11Z [go-api] container restarting... (backoff: 10s)
2026-04-27T10:00:21Z [go-api] FATAL: DATABASE_URL is not set
2026-04-27T10:00:21Z [go-api] exit status 1
2026-04-27T10:00:26Z [go-api] container restarting... (backoff: 20s)

Press Ctrl+C to stop the stream. Kubernetes is in CrashLoopBackOff, doubling the restart delay each time. Normal — move to the next step.

Terminal window
1ctl secret create \
--kv DATABASE_URL=postgres://api-user:[email protected]:5432/myapp?sslmode=require

Expected output:

✅ Secret go-api created successfully

secret create merges: it only updates the keys you pass. Any other secrets already attached to this deployment are left unchanged. The secret is now stored encrypted and associated with the deployment, but the running (crashing) pod doesn’t have it yet — pods pick up changes on the next start.

Terminal window
1ctl env create \
--env DB_MAX_CONNECTIONS=25 \
--env DB_POOL_TIMEOUT=30s

Expected output:

✅ Environment go-api created successfully

These are tuning knobs, not credentials — env vars are the right tool here. Your application reads them with os.Getenv("DB_MAX_CONNECTIONS") and passes the value to db.SetMaxOpenConns. Like secrets, env vars also merge: only the keys you pass are touched.

Terminal window
1ctl deploy restart

Expected output:

💡 Initiating rolling restart for deployment <id>...
✅ Rolling restart initiated. Pods are being replaced one by one.
💡 Use '1ctl deploy status --deployment-id <id>' to monitor progress.

deploy restart does a rolling replacement: it starts a new pod with the updated secrets and env vars, waits for it to pass its readiness check, then terminates the old pod. Traffic is routed to healthy instances throughout — zero downtime. No rebuild happens; the existing image is reused.

Terminal window
1ctl logs stream

Expected output:

2026-04-27T10:05:14Z [go-api] connected to database in 42ms
2026-04-27T10:05:14Z [go-api] server listening on :8080
2026-04-27T10:05:31Z [go-api] GET /health 200 91µs

Press Ctrl+C when you’ve seen enough. For a machine-readable health check:

Terminal window
1ctl -o json deploy get

Expected output:

{
"deployment_id": "4m9rk2x7-5f87-4612-b306-3da846b95d18",
"app_label": "go-api",
"status": "completed",
"replicas": 1,
"cpu_request": "0.5",
"memory_request": "256Mi",
"memory_limit": "256Mi",
"port": 8080,
"image": "registry.satusky.com/...",
"domain": "https://quietpanda-r7w4p1.satusky.com",
"created_at": "2026-04-27T10:00:00+08:00",
"updated_at": "2026-04-27T10:05:10+08:00"
}

"status": "completed" means the deployment is fully healthy. Use this in health-check scripts — the -o json output is stable and machine-parseable.

Decided DB_POOL_TIMEOUT should use the driver’s built-in default instead:

Terminal window
1ctl env unset --key DB_POOL_TIMEOUT

Expected output:

✅ Key "DB_POOL_TIMEOUT" removed from environment

env unset removes exactly the one key you name. DB_MAX_CONNECTIONS and any other variables are untouched. Restart to apply:

Terminal window
1ctl deploy restart

Your database provider has issued new credentials. Update the secret by running secret create again with the new value:

Terminal window
1ctl secret create \
--kv DATABASE_URL=postgres://api-user:[email protected]:5432/myapp?sslmode=require

Expected output:

✅ Secret go-api created successfully

Because secret create merges, only DATABASE_URL is updated. No other keys in the secret group are touched. Then restart:

Terminal window
1ctl deploy restart

The new pod starts with the rotated credential. The old pod is terminated only after the new one passes its readiness check.

Never put DATABASE_URL in satusky.toml. Config files end up in version control. Secrets do not.

Use -o json in health-check scripts. 1ctl -o json deploy get gives you a stable structure you can pipe through jq. Example: 1ctl -o json deploy get | jq -e '.status == "completed"' exits 0 on healthy, 1 otherwise.

Rolling restart stalled? Run 1ctl logs stream immediately. If the new pod is crashing (wrong credentials, missing env var, unreachable database), the crash reason appears in the stream within seconds. The old pod stays up until the new one is healthy, so there’s no outage while you diagnose.

secret create is idempotent on the keys you pass. Calling it multiple times with the same value is safe — it upserts, it does not create duplicates.