A clone-ready Go module that pre-wires every github.com/plinth-dev/sdk-go/* package into a working HTTP service. One sample resource (items) shipped end-to-end so the integration of every SDK module is visible from cmd/server/main.go.
Pre-wired:
- Authentication middleware (starter-grade — replace before production).
- Authorization via Cerbos PDP (fail-closed —
CheckActionreturnsAllowed: false, Reason: Unreachableon any error). - Audit events via
sdk-go/audit(non-blocking; in-memory producer for dev, swap for NATS / Kafka in production). - OpenTelemetry traces via OTLP/HTTP.
- RFC 7807 problem+json error responses via
sdk-go/errorsmiddleware. - Health probes at
/livezand/healthz//readyz. - Pagination via
sdk-go/paginatewith sort-column allow-listing. - Config via
sdk-go/vault(Kubernetes secret-mounts → env, layered). - Three-layer architecture —
internal/handlers/→internal/service/→internal/repository/. Cross-layer boundaries are enforced by Go'sinternal/visibility rules.
See plinth.run for the SDK design rationale.
Requirements: Go 1.25+, Docker.
# Clone and rename for your module.
git clone https://github.com/plinth-dev/starter-api my-module-api
cd my-module-api
# Bring up Postgres + Cerbos.
docker compose up -d postgres cerbos
# Run the API directly (fast iteration).
make runBy default the API listens on :8080. Try it:
# Health probe (no auth).
curl -s http://localhost:8080/healthz | jq
# Create an item. The starter's dev-only token format is "<userid>:<role1>,<role2>".
curl -s -X POST http://localhost:8080/items \
-H "Authorization: Bearer alice:editor" \
-H "Content-Type: application/json" \
-d '{"name": "thing", "status": "active"}' | jq
# List with pagination + sorting.
curl -s "http://localhost:8080/items?page=1&pageSize=10&sortBy=created_at&sortOrder=desc" \
-H "Authorization: Bearer alice:viewer" | jq
# Validation failure → RFC 7807 problem+json.
curl -s -X POST http://localhost:8080/items \
-H "Authorization: Bearer alice:editor" \
-H "Content-Type: application/json" \
-d '{"name": "", "status": "what"}' | jqFor a fully containerised stack (API in a container too):
docker compose --profile full up --build.
├── cmd/server/ # main.go — wires every SDK module
├── internal/
│ ├── config/ # sdk-go/vault → typed Config
│ ├── handlers/ # HTTP handlers; thin
│ ├── service/ # Business logic; authz + audit
│ ├── repository/ # pgx queries
│ └── middleware/ # Auth shim, etc.
├── db/migrations/ # SQL applied by docker-compose's postgres init
├── cerbos/
│ ├── config.yaml # PDP config
│ └── policies/item.yaml # Resource policy for `Item`
├── docker-compose.yml # postgres + cerbos (and the API behind --profile full)
├── Dockerfile # Distroless multi-stage build
└── Makefile
+-----------------+
HTTP request | Chi router |
+-----------------+
|
+-----------+-----------+
| OTel HTTP middleware | sdk-go/otel: starts a span
+-----------+-----------+
|
+-----------+-----------+
| Auth middleware | internal/middleware: parses bearer,
+-----------+-----------+ sets AuthContext on the request
|
+-----------+-----------+
| Errors middleware | sdk-go/errors: catches errors set via
+-----------+-----------+ apperrors.SetError, renders RFC 7807
|
+-----------+-----------+
| Handlers | internal/handlers: parse + validate +
+-----------+-----------+ delegate to service
|
+-----------+-----------+
| Service | internal/service: authz check (Cerbos),
+-----------+-----------+ audit emission, business logic
|
+-----------+-----------+
| Repository | internal/repository: pgx SQL
+-----------+-----------+
|
[Postgres]
Authz lives in the service layer, never in handlers — handlers shouldn't know what "comment on a closed item" means semantically.
internal/middleware/auth.go parses Authorization: Bearer <userid>:<role1>,<role2>. This is for local development only, so you can curl with -H "Authorization: Bearer alice:editor" and see the authz layer work without standing up a real IdP.
Replace with your project's actual auth before production. The replacement contract:
- Take the
Authorizationheader (or session cookie) off the request. - Validate it. Reject invalid tokens with
apperrors.Unauthenticated(...). - Build a
service.AuthContext{ UserID, Roles, JWT, TraceID }. - Stick it on
r.Context()somiddleware.AuthFromContext(ctx)can retrieve it.
Drop-in candidates: Auth0, Clerk, Stack, Ory Kratos, or a homegrown OIDC client. The contract is AuthContext, not the implementation.
cerbos/policies/item.yaml is the starter policy for the Item resource:
read/list/create: any of the three roles (viewer,editor,admin).update/delete: aneditorwho owns the row (request.resource.attr.owner_id == request.principal.id), OR anyone with theadminrole.- A
viewerwho happens to own a row is not allowed to mutate it — write capability is gated on role, not just ownership.
Tweak for your domain — Cerbos hot-reloads on file change.
The dev cerbos/config.yaml enables the disk driver pointing at ./policies and disables Cerbos's own audit log (we have our own audit pipeline). For production, swap to git-driven storage.
After cloning:
go.mod— change the module path:github.com/<org>/<module>-api.internal/config/config.go— adjust required env keys for your service.cerbos/policies/— replaceItemwith your resource kind(s).- String literals that match the Cerbos kind:
internal/service/items.goreferencesKind: "Item"in two places (the resource passed toauthz.CheckActionand the auditResource.Kind). Rename both — Cerbos returnsDeniedif the kind in the request doesn't match the policy file, so a stale literal is a silent authorization failure. db/migrations/— replace theitemstable with your schema.internal/repository/items.go,internal/service/items.go,internal/handlers/items.go— rename / extend for your resource(s).cmd/server/main.go— register your additional handlers.- Replace
internal/middleware/auth.gowith real auth before going to production.
The starter is clone-ready, not production-ready out of the box. Before deploying:
- Set
APP_ENV=production. This is the single most important env switch:sdk-go/authz.NewrejectsCERBOS_ALLOW_BYPASS=1only whenAPP_ENV=production, and audit defaults flip with it. Without this, a misconfigured deploy can silently bypass authz checks. - Unset
CERBOS_ALLOW_BYPASS(and never set it in any production environment file). The check above is your last line of defence; relying on it without removing the variable is brittle. - Replace the auth middleware (above).
- Swap the audit
MemoryProducerfor a NATS / Kafka producer; otherwise audit events vanish on restart. - Set
CERBOS_TLS=trueand supply a real CA bundle if your Cerbos PDP has TLS. - Set
OTEL_EXPORTER_OTLP_ENDPOINTto your collector; the default endpoint ofhttp://otel-collector.observability:4318is for an in-cluster default. - Run
db/migrationsvia your migration tool of choice (golang-migrate, atlas, dbmate). The starter'sdocker-composeonly runs them on first volume creation. - Ensure secrets land at
/run/secrets/<KEY>(Kubernetes default) —sdk-go/vaultreads file-mounted secrets first, then env.
starter-web— the matching Next.js frontend.sdk-go— the SDK packages this starter imports.platform— the Kubernetes Helm chart that runs the surrounding observability + auth stack.
MIT — see LICENSE.