Skip to content

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.

Confirm that 1ctl, Go, jq, and curl are available:

Terminal window
1ctl auth status
go version
jq --version
curl --version

Choose names that are unique within your organization:

Terminal window
export APP="notes-api"
export PG="${APP}-postgres"

Create a one-instance PostgreSQL 17 cluster:

Terminal window
1ctl postgres create "$PG" \
--database notes \
--user notes_app \
--version 17 \
--instances 1 \
--storage-size 10Gi \
--cpu 250m \
--memory 256Mi

Wait until the cluster and its connection pool are ready:

Terminal window
1ctl postgres status "$PG"

Continue when the status shows a healthy cluster, 1/1 ready instance, and 2/2 ready pooler replicas.

Create a new project:

Terminal window
mkdir notes-api
cd notes-api
go mod init notes-api
go mod edit -go=1.23
go get github.com/jackc/pgx/v5/pgxpool@v5.7.6

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

Terminal window
go fmt ./...
go mod tidy

Create Dockerfile:

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

Create satusky.toml:

[app]
name = "notes-api"
port = 8080
cpu_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 once so SatuSky can create the application identity that will own the config and secret:

Terminal window
1ctl deploy --wait
1ctl app status "$APP"

The first pod serves {"status":"configuring"} because credentials have not been attached yet.

Store the non-secret pool setting as application config:

Terminal window
1ctl config create --app "$APP" --env DB_MAX_CONNECTIONS=10

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

Terminal window
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_SECRET
rm -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:

Terminal window
1ctl app status "$APP"
1ctl logs --app "$APP" --tail 50

Continue when the workload is running and the logs contain:

database connected and migrated

Copy the HTTPS URL from 1ctl app status:

Terminal window
export URL="https://your-assigned-hostname.satusky.com"
curl --fail --silent --show-error "$URL/health" | jq

Create and read a note:

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

Terminal window
1ctl app restart "$APP"
1ctl app status "$APP"

After the replacement workload is running, read the same row again:

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

Creating or updating config does not remove keys that you omit from a later command. Remove a key explicitly:

Terminal window
1ctl config unset --app "$APP" --key DB_MAX_CONNECTIONS

Use the equivalent explicit command for a secret key:

Terminal window
1ctl secret unset --app "$APP" --key DATABASE_URL

See Environment configuration for the complete config and secret lifecycle instead of duplicating it here.

Delete the application before deleting its database:

Terminal window
1ctl app delete "$APP" --yes
1ctl postgres delete "$PG" --yes

Confirm that both resources are gone:

Terminal window
1ctl app list
1ctl postgres list