Deploy an API with PostgreSQL
This guide deploys a notes API and connects it to SatuSky managed PostgreSQL. You will verify the connection through the public API, write and read a row, restart the application, and confirm that the row remains.
Before you start
Section titled “Before you start”Confirm that 1ctl, Go, jq, and curl are available:
1ctl auth statusgo versionjq --versioncurl --versionChoose names that are unique within your organization:
export APP="notes-api"export PG="${APP}-postgres"Create managed PostgreSQL
Section titled “Create managed PostgreSQL”Create a one-instance PostgreSQL 17 cluster:
1ctl postgres create "$PG" \ --database notes \ --user notes_app \ --version 17 \ --instances 1 \ --storage-size 10Gi \ --cpu 250m \ --memory 256MiWait until the cluster and its connection pool are ready:
1ctl postgres status "$PG"Continue when the status shows a healthy cluster, 1/1 ready instance, and
2/2 ready pooler replicas.
Create the API
Section titled “Create the API”Create a new project:
mkdir notes-apicd notes-apigo mod init notes-apigo mod edit -go=1.23go get github.com/jackc/pgx/v5/pgxpool@v5.7.6Create main.go:
package main
import ( "context" "encoding/json" "log" "net/http" "os" "strconv"
"github.com/jackc/pgx/v5/pgxpool")
type app struct { db *pgxpool.Pool}
type note struct { ID int64 `json:"id"` Body string `json:"body"`}
func main() { ctx := context.Background() var pool *pgxpool.Pool
if databaseURL := os.Getenv("DATABASE_URL"); databaseURL != "" { config, err := pgxpool.ParseConfig(databaseURL) if err != nil { log.Fatal(err) } if value := os.Getenv("DB_MAX_CONNECTIONS"); value != "" { maxConnections, err := strconv.Atoi(value) if err != nil { log.Fatal(err) } config.MaxConns = int32(maxConnections) }
pool, err = pgxpool.NewWithConfig(ctx, config) if err != nil { log.Fatal(err) } if err = pool.Ping(ctx); err != nil { log.Fatal(err) } if _, err = pool.Exec(ctx, ` CREATE TABLE IF NOT EXISTS notes ( id BIGSERIAL PRIMARY KEY, body TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ) `); err != nil { log.Fatal(err) } log.Println("database connected and migrated") defer pool.Close() } else { log.Println("DATABASE_URL not attached yet") }
server := &app{db: pool} mux := http.NewServeMux() mux.HandleFunc("/health", server.health) mux.HandleFunc("/notes", server.notes)
log.Println("listening on 0.0.0.0:8080") log.Fatal(http.ListenAndServe("0.0.0.0:8080", mux))}
func (a *app) health(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "application/json") if a.db == nil { _ = json.NewEncoder(w).Encode(map[string]string{"status": "configuring"}) return } _ = json.NewEncoder(w).Encode(map[string]string{ "status": "ok", "database": "connected", })}
func (a *app) notes(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "application/json") if a.db == nil { http.Error(w, `{"error":"database not configured"}`, http.StatusServiceUnavailable) return }
switch r.Method { case http.MethodGet: rows, err := a.db.Query(r.Context(), "SELECT id, body FROM notes ORDER BY id") if err != nil { http.Error(w, `{"error":"query failed"}`, http.StatusInternalServerError) return } defer rows.Close()
notes := make([]note, 0) for rows.Next() { var item note if err = rows.Scan(&item.ID, &item.Body); err != nil { http.Error(w, `{"error":"scan failed"}`, http.StatusInternalServerError) return } notes = append(notes, item) } _ = json.NewEncoder(w).Encode(notes)
case http.MethodPost: var input struct { Body string `json:"body"` } if err := json.NewDecoder(r.Body).Decode(&input); err != nil || input.Body == "" { http.Error(w, `{"error":"body is required"}`, http.StatusBadRequest) return }
var created note err := a.db.QueryRow( r.Context(), "INSERT INTO notes(body) VALUES($1) RETURNING id, body", input.Body, ).Scan(&created.ID, &created.Body) if err != nil { http.Error(w, `{"error":"insert failed"}`, http.StatusInternalServerError) return } w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(created)
default: w.Header().Set("Allow", "GET, POST") http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) }}Format the source and resolve dependencies:
go fmt ./...go mod tidyCreate Dockerfile:
FROM golang:1.23-alpine AS builderWORKDIR /appCOPY go.mod go.sum ./RUN go mod downloadCOPY main.go ./RUN CGO_ENABLED=0 go build -o server .
FROM alpine:3.20RUN apk add --no-cache ca-certificatesWORKDIR /appCOPY --from=builder /app/server .USER 65532:65532EXPOSE 8080ENTRYPOINT ["./server"]Create satusky.toml:
[app]name = "notes-api"port = 8080cpu_request = "50m"cpu_limit = "200m"memory = "256Mi"replicas = 1
[build]dockerfile = "Dockerfile"
[checks]health_path = "/health"
[deploy]strategy = "rolling"rolling_max_surge = "1"rolling_max_unavailable = "0"If you changed APP, set the same value as [app].name.
Deploy the API
Section titled “Deploy the API”Deploy once so SatuSky can create the application identity that will own the config and secret:
1ctl deploy --wait1ctl app status "$APP"The first pod serves {"status":"configuring"} because credentials have not
been attached yet.
Attach config and credentials
Section titled “Attach config and credentials”Store the non-secret pool setting as application config:
1ctl config create --app "$APP" --env DB_MAX_CONNECTIONS=10Capture the managed connection details in a restricted temporary file. The following commands do not print the connection URL or put a literal password in shell history:
CREDENTIALS_FILE="$(mktemp)"chmod 600 "$CREDENTIALS_FILE"1ctl postgres credentials "$PG" --output json > "$CREDENTIALS_FILE"
DB_SECRET="DATABASE_URL=$(jq -er '.internal_uri' "$CREDENTIALS_FILE")"1ctl secret create --app "$APP" "$DB_SECRET"
unset DB_SECRETrm -f "$CREDENTIALS_FILE"Config and secret projections are independent: updating one must not remove the other. Each command restarts the application when required.
Check the final rollout:
1ctl app status "$APP"1ctl logs --app "$APP" --tail 50Continue when the workload is running and the logs contain:
database connected and migratedVerify writes and persistence
Section titled “Verify writes and persistence”Copy the HTTPS URL from 1ctl app status:
export URL="https://your-assigned-hostname.satusky.com"curl --fail --silent --show-error "$URL/health" | jqCreate and read a note:
export NOTE="persists-$(date +%s)"
curl --fail --silent --show-error \ -X POST \ -H "Content-Type: application/json" \ --data "$(jq -nc --arg body "$NOTE" '{body: $body}')" \ "$URL/notes" | jq
curl --fail --silent --show-error "$URL/notes" | jq -e --arg body "$NOTE" 'any(.[]; .body == $body)'Restart only the application:
1ctl app restart "$APP"1ctl app status "$APP"After the replacement workload is running, read the same row again:
curl --fail --silent --show-error "$URL/notes" | jq -e --arg body "$NOTE" 'any(.[]; .body == $body)'A successful result proves that the row is stored in PostgreSQL rather than in the application container.
Updating and removing config
Section titled “Updating and removing config”Creating or updating config does not remove keys that you omit from a later command. Remove a key explicitly:
1ctl config unset --app "$APP" --key DB_MAX_CONNECTIONSUse the equivalent explicit command for a secret key:
1ctl secret unset --app "$APP" --key DATABASE_URLSee Environment configuration for the complete config and secret lifecycle instead of duplicating it here.
Clean up
Section titled “Clean up”Delete the application before deleting its database:
1ctl app delete "$APP" --yes1ctl postgres delete "$PG" --yesConfirm that both resources are gone:
1ctl app list1ctl postgres list