template-go-api is the Meigma starter for building Go web (HTTP) API services.
It ships a runnable, hexagonal API server — built on chi
and Huma with a todo example resource — plus the shared
Meigma repository baseline: Moon tasks, pinned CI, Dependabot, baseline security
settings, and an enabled Release Please and GoReleaser release layer.
Persistence is a PostgreSQL adapter behind the domain's todo.Repository port
(pgx + sqlc typed queries,
goose migrations). The port stays the seam:
implement it to back the template with a different datastore without touching the
domain or transport.
The example resource is a reference slice, not a product feature: swap it for your own resource and keep the persistence wiring.
- Go 1.26.4
- Moon 2.x
- Python 3.14.3 and uv 0.11.0 (only for the MkDocs documentation project)
- Docker (to run a local PostgreSQL for the server — see Persistence and Local stack — and for the container-backed integration tests)
The sqlc, goose, and mockery CLIs are pinned in .prototools and run
through Proto, so they are fetched on demand by Moon — there is nothing to install
by hand. Each Proto plugin verifies its download: goose, mockery, and
golangci-lint check the upstream checksum file (checksum-url), while sqlc
(which publishes no checksum) is verified by the sqlc-verify task against a
repo-pinned digest in .moon/proto/sqlc.sha256 before it runs. Bumping the sqlc
version in .prototools therefore also means refreshing those digests — the
regeneration recipe is in that file's header.
New repository from this template? Work through DELETE_ME.md first — it covers renaming the module, binary, image, and env prefix, and replacing the example resource.
The server persists to PostgreSQL, so running it needs a database. The fastest way to bring up the whole stack — database, migrations, seed data, and the API — is Docker Compose:
docker compose up --build # API on :8080, /metrics on :9090 (see "Local stack" below)To build the binary and run it against your own PostgreSQL instead, see
Persistence (serve is the default subcommand and needs
--database-url):
moon run root:build # or: go build -o bin/template-go-api ./cmd/template-go-apiWith the stack up, exercise the example todo API. The todo routes are
protected by the Authorization tier (on by default), so each
request carries one of the dev keys the Compose stack seeds — dev-user-key
sent via the X-API-Key header:
# Without a key, protected routes reject the request:
curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/v1/todos
# => 401
# Create a todo (the user key has the role the todo policy requires):
curl -sS -X POST localhost:8080/v1/todos \
-H 'X-API-Key: dev-user-key' \
-H 'content-type: application/json' \
-d '{"title":"buy milk"}'
# => 201 {"$schema":"...","id":"...","title":"buy milk","status":"open","createdAt":"..."}
curl -sS -H 'X-API-Key: dev-user-key' localhost:8080/v1/todos # list (first page, default 20)
curl -sS -H 'X-API-Key: dev-user-key' 'localhost:8080/v1/todos?limit=50&cursor=<next>' # next page
curl -sS -H 'X-API-Key: dev-user-key' localhost:8080/v1/todos/<id> # fetch one (404 if unknown)
curl -sS -X POST -H 'X-API-Key: dev-user-key' localhost:8080/v1/todos/<id>/complete # mark complete
# Validation and not-found errors use RFC 9457 problem+json:
curl -sS -i -X POST localhost:8080/v1/todos \
-H 'X-API-Key: dev-user-key' -H 'content-type: application/json' -d '{"title":""}'
# => 422 application/problem+jsonThese mock keys are dev-only seeds; see Authorization for how authn/authz work and how to plug in a real authenticator.
Resource routes are served under a /v1 URL version prefix; the operational
endpoints below (/healthz, /readyz, /metrics, /docs, /openapi.*) are
not part of the versioned contract and stay at the root. See
API versioning for how a later /v2 is added.
GET /v1/todos is keyset-paginated: it returns at most limit todos (default
20, max 100; an out-of-range limit is a 422) ordered by (createdAt, id), plus
an opaque nextCursor. Pass that cursor back as ?cursor= to fetch the next
page; the last page omits it. The bound is applied even when limit is absent,
so a single request can never materialize the whole table — the reference
pattern for a bounded collection endpoint. Cross-cutting rate limiting is a
deliberate future seam and is not shipped.
Operational endpoints:
curl -sS localhost:8080/healthz # liveness => {"status":"ok"}
curl -sS localhost:8080/readyz # readiness => {"status":"ready","checks":{}}
curl -sS localhost:9090/metrics # Prometheus exposition (separate listener)/metrics is served on a dedicated listener (--metrics-addr, default :9090)
so it stays off the public API surface and outside the API middleware chain; set
--metrics-addr "" to co-locate it on the API port instead.
The running server also serves interactive API docs at /docs (Stoplight
Elements) and the live spec at /openapi.yaml and /openapi.json.
docker compose up --build brings up the full template against PostgreSQL —
no local Go toolchain or database setup required. The stack also seeds the
dev-only mock API keys (hack/sql/0002_seed_api_keys.sql), so the
Authorization tier is exercised end to end out of the box:
docker compose up --build
# Authorization is on by default. With no key, a protected route returns 401:
curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/v1/todos # => 401
# With the seeded dev user key, the same route returns 200 and the seeded todos:
curl -sS -H 'X-API-Key: dev-user-key' localhost:8080/v1/todos # => 200, the seeded todos
curl -sS localhost:8080/readyz # => {"status":"ready","checks":{"postgres":"ok"}}/readyz (and the other operational endpoints) are raw routes outside the Huma
authorization middleware, so they need no key.
Startup is an ordered DAG, because migrations are explicit (the server never runs them) and the seed data needs the schema to exist first:
| Step | Service | What it does |
|---|---|---|
| 1 | postgres |
PostgreSQL 17 with a known config; the stack waits for pg_isready. |
| 2 | migrate |
One-shot migrate up — applies the embedded goose migrations. |
| 3 | seed |
One-shot — applies every hack/sql/*.sql (sorted) with psql. |
| 4 | api |
Serves the API against the prebaked connection string. |
The database is ephemeral and reproducible: no volume is persisted, so every
up rebuilds a clean database, re-runs migrations, and re-applies the seeds;
docker compose down discards it.
Prepopulate local data by dropping SQL files in hack/sql/ — they
run after the schema exists, so you can INSERT straight into tables like todos
without touching migrations or adding setup code to the server. The bundled
hack/sql/0001_seed_todos.sql seeds a few todos so the API returns data on the
first request, and hack/sql/0002_seed_api_keys.sql seeds the dev-only mock
API keys (dev-user-key, dev-admin-key) that the Authorization
demo uses. These seeds are local-development only and never reach a real
deployment (migrations run everywhere; hack/sql/ does not).
| Command | Description |
|---|---|
serve (default) |
Run the HTTP API server. |
version |
Print version, commit, and build date. |
openapi |
Write the OpenAPI 3.0.3 spec to stdout or a file (--output/-o). |
migrate up|down|status |
Apply, roll back, or report the embedded PostgreSQL migrations against --database-url. |
./bin/template-go-api openapi -o docs/docs/openapi.yaml
./bin/template-go-api version
./bin/template-go-api migrate status --database-url postgres://app:app@localhost:5432/app?sslmode=disableFlags bind to Viper, so every setting is also a TEMPLATE_GO_API_* environment
variable (uppercase, dashes become underscores). Precedence is flag > env >
default.
| Flag | Env var | Default | Description |
|---|---|---|---|
--addr |
TEMPLATE_GO_API_ADDR |
:8080 |
host:port the API listens on |
--metrics-addr |
TEMPLATE_GO_API_METRICS_ADDR |
:9090 |
dedicated /metrics listener; empty serves /metrics on --addr |
--log-level |
TEMPLATE_GO_API_LOG_LEVEL |
info |
debug, info, warn, or error |
--log-format |
TEMPLATE_GO_API_LOG_FORMAT |
json |
json or text |
--read-timeout |
TEMPLATE_GO_API_READ_TIMEOUT |
5s |
reading an entire request |
--read-header-timeout |
TEMPLATE_GO_API_READ_HEADER_TIMEOUT |
5s |
reading request headers |
--write-timeout |
TEMPLATE_GO_API_WRITE_TIMEOUT |
10s |
writing the response |
--idle-timeout |
TEMPLATE_GO_API_IDLE_TIMEOUT |
120s |
idle keep-alive connections |
--request-timeout |
TEMPLATE_GO_API_REQUEST_TIMEOUT |
15s |
per-request processing |
--shutdown-grace |
TEMPLATE_GO_API_SHUTDOWN_GRACE |
15s |
graceful shutdown window |
--cors-allowed-origins |
TEMPLATE_GO_API_CORS_ALLOWED_ORIGINS |
(none) | allowed CORS origins (comma-separated); empty disables CORS |
--trusted-proxy-header |
TEMPLATE_GO_API_TRUSTED_PROXY_HEADER |
(none) | proxy header to read the client IP from (e.g. X-Real-IP); empty trusts the TCP peer |
--database-url |
TEMPLATE_GO_API_DATABASE_URL |
(none) | PostgreSQL connection URL (required) |
--db-max-conns |
TEMPLATE_GO_API_DB_MAX_CONNS |
0 |
maximum PostgreSQL pool connections; 0 uses the driver default |
--authz-enabled |
TEMPLATE_GO_API_AUTHZ_ENABLED |
true |
enable the authorization middleware (deny-by-default); false bypasses it entirely |
--authz-policy-dir |
TEMPLATE_GO_API_AUTHZ_POLICY_DIR |
(none) | directory of .cedar files to load instead of the embedded policies; empty uses the embedded set |
--rate-limit-enabled |
TEMPLATE_GO_API_RATE_LIMIT_ENABLED |
true |
enable per-client rate limiting; false disables throttling entirely |
--rate-limit-rps |
TEMPLATE_GO_API_RATE_LIMIT_RPS |
10 |
sustained per-client request rate (requests/second) |
--rate-limit-burst |
TEMPLATE_GO_API_RATE_LIMIT_BURST |
20 |
per-client burst size (token-bucket depth) |
--tracing-enabled |
TEMPLATE_GO_API_TRACING_ENABLED |
false |
enable OpenTelemetry tracing; the OTLP exporter is configured via the standard OTEL_* env vars |
CORS is off until you set origins. Client IP is read from the direct TCP peer
unless you opt into a trusted proxy header — never from X-Forwarded-For
implicitly — so the default is not spoofable.
--database-url is required; the server rejects a missing URL at startup.
The todo.Repository port is implemented by a PostgreSQL adapter under
internal/todo/postgres: sqlc type-safe queries over a
pgx connection pool, with
goose migrations. The shared connection pool
and migration machinery stay under internal/adapter/postgres as database-level
concerns. The composition root in internal/app/app.go wires the adapter and
registers a postgres readiness check so /readyz reflects database
connectivity.
The port is the extension seam: to back the template with a different datastore,
implement todo.Repository in a new adapter and wire it in app.go (or inject it
via app.WithRepository) — the domain and transport layers stay untouched.
Start a database (any reachable PostgreSQL works; this is just an example):
docker run --rm -d --name template-pg \
-e POSTGRES_PASSWORD=app -e POSTGRES_USER=app -e POSTGRES_DB=app \
-p 5432:5432 postgres:17-alpine
export TEMPLATE_GO_API_DATABASE_URL='postgres://app:app@localhost:5432/app?sslmode=disable'Apply migrations, then serve against the database:
./bin/template-go-api migrate up # create the schema
./bin/template-go-api serve # reads TEMPLATE_GO_API_DATABASE_URL
curl -sS localhost:8080/readyz # => {"status":"ready","checks":{"postgres":"ok"}}--database-url (env TEMPLATE_GO_API_DATABASE_URL) is shared by serve and
migrate. The connection URL can also be passed as a flag instead of an
environment variable.
Migrations live in internal/adapter/postgres/migrations/*.sql (goose format) and
are embedded in the binary. They are explicit — serve never runs them, which
avoids multi-replica races. The migrate subcommand drives goose as a library:
./bin/template-go-api migrate up # apply all pending migrations
./bin/template-go-api migrate status # show applied/pending versions
./bin/template-go-api migrate down # roll back the most recent migrationMoon wraps the same command for local dev (arguments after -- pass through):
moon run root:migrate -- up --database-url "$TEMPLATE_GO_API_DATABASE_URL"Scaffold a new migration file with the Proto-managed goose CLI, then edit its
-- +goose Up / -- +goose Down sections:
proto run goose -- -dir internal/adapter/postgres/migrations create add_something sqlBecause sqlc reads the migrations directory as its schema, a schema change means regenerating the typed query layer (below).
Hand-written queries live in internal/todo/postgres/queries/todos.sql; sqlc
generates the typed Go in internal/todo/postgres/sqlc/ from those queries and
the migration schema. The generated package is committed and drift-guarded
(mirroring the openapi / openapi-check pattern). After changing a migration or
a query, regenerate and commit:
moon run root:sqlc # regenerate internal/todo/postgres/sqlc/moon run root:sqlc-check (part of root:check) regenerates into a throwaway
directory and fails if the committed code is stale, so the generated layer can
never drift from the schema and queries. The sqlc.yaml config maps the uuid
column to github.com/google/uuid.UUID and timestamptz to time.Time /
*time.Time; the adapter converts to and from the domain's string ID and
Status at the mapping boundary.
Integration tests live in their own package, internal/integration (package
integration, //go:build integration), separate from the unit tests that sit
beside the code, and drive the adapters through their public APIs. The suite is
container-backed and behind the build tag, so the default go test ./... and
moon run root:check stay hermetic (no Docker). It uses
testcontainers to spin a throwaway
postgres:17-alpine, applies the embedded migrations, and snapshots the clean
schema for fast per-test isolation. It requires a running Docker daemon:
moon run root:test-integration # or: go test -tags integration ./internal/integration/...The container-backed integration suite runs in CI through moon ci on the
Docker-capable ubuntu-latest runner. It stays out of the hermetic
moon run root:check aggregate, so a local check never needs Docker.
This template ships no query-builder dependency. The three port methods are
fixed queries, and List is parameter-free. For optional filtering that sqlc can
still type-check, use sqlc.narg() (nullable named arguments) — a commented
example lives in queries/todos.sql: a NULL argument disables the filter, a
non-NULL value applies it, all in one prepared statement.
When you genuinely need queries assembled at runtime (variable column sets,
arbitrary AND/OR trees), keep that complexity inside the adapter, behind the
port. Define a criteria struct on the port and translate it to SQL in the
PostgreSQL adapter, reaching for Squirrel
or Bob there:
// In the domain port (internal/todo) — no builder types appear here.
type TodoFilter struct {
Status *Status // nil means "any status"
}
// List(ctx context.Context, f TodoFilter) ([]Todo, error)The rule: query-builder types must never appear in a port signature. The domain speaks in domain criteria; only the adapter knows SQL. Swapping Squirrel for Bob, or back to plain sqlc, then stays a change inside one package.
The template ships an authorization tier built on
AWS Cedar via
cedar-policy/cedar-go as the
embedded policy engine. Policies are real .cedar source, embedded in the binary
(or loaded from a directory with --authz-policy-dir), and evaluated by one
global Huma middleware.
The posture is deny-by-default: every Huma API operation must declare its
authorization requirement, and an operation that declares none is denied
(fail-closed) and logged. Denials are returned as RFC 9457 problem responses —
401 when no/invalid credential was presented, 403 when an authenticated
caller lacks the required role. The operational routes (/healthz, /readyz,
/metrics, /openapi.*, /docs) are raw chi routes outside the Huma
middleware, so they are never gated.
--authz-enabled (default true) is the master switch; setting it false
bypasses the middleware entirely (an escape hatch for incremental adoption or
local debugging). The declaration also populates each operation's OpenAPI
security, so protection is visible in the generated spec and at /docs.
Authentication is not something the template decides for you. It ships the
seam — an Authenticator interface (internal/authz) — plus one replaceable
starting point: an API-key authenticator (internal/authz/apikey).
The shipped authenticator reads a key from the X-API-Key header or an
Authorization: Bearer <key> credential and resolves it through an APIKeyStore
port to a principal (subject + roles). The shipped store is PostgreSQL-backed:
the api_keys table (created by migration 00002_create_api_keys.sql) stores
only a SHA-256 hash of each key (key_hash), never the key itself, and the
store hashes the presented credential before lookup — so a table or backup
disclosure exposes no replayable credentials. This is still a real but minimal
mechanism — enough to demonstrate the full flow — not production authn:
replacing it with a real verifier is called out in DELETE_ME.md.
For local development the Compose stack seeds two mock keys via
hack/sql/0002_seed_api_keys.sql:
| Key | Roles | Authorized for |
|---|---|---|
dev-user-key |
user |
all todo actions (the todo slice's policy) |
dev-admin-key |
admin |
everything (the cross-cutting admin override, base.cedar) |
These are insecure, public, dev-only credentials; real deployments insert
their own api_keys rows and never apply hack/sql/.
To swap in real authn (JWT/OIDC/session), implement authz.Authenticator and
inject it with app.WithAuthenticator; nothing else in the tier changes.
Authorization is modular in the same way the HTTP transport is: each domain
contributes an authz slice and the composition root merges all contributions
into one runtime engine. The todo slice lives at internal/todo/authz and
contributes three things via an authz.Contribution:
- Policies — embedded
policy.cedarsource (merged withbase.cedarand every other slice's policies into one CedarPolicySet). - Actions — typed Cedar action identifiers (
todoauthz.ActionCreate,ActionRead,ActionUpdate,ActionList), each of the formAction::"todo:<verb>". - A fact resolver — maps a
Todoentity to its Cedar attributes/parents, loaded lazily (only when a policy dereferences a todo, which the shipped coarse policy never does).
The HTTP slice tags each operation with its action at registration, using
authz.Require (or authz.Public to opt out):
huma.Register(api, huma.Operation{
OperationID: "get-todo",
Method: http.MethodGet,
Path: "/todos/{id}",
// Item operation: bind the {id} path param so the middleware sets
// Resource = Todo::"<id>" straight from the matched route (no load).
Metadata: authz.Require(todoauthz.ActionRead, "id"),
}, h.get)
huma.Register(api, huma.Operation{
OperationID: "list-todos",
Method: http.MethodGet,
Path: "/todos",
// Collection operation: type-level resource, no id bound.
Metadata: authz.Require(todoauthz.ActionList),
}, h.list)To add authorization to a new resource, mirror the todo slice:
- Add
internal/<resource>/authzwithpolicy.cedar(embedded), typed action constants, a fact resolver, and aContribution(repo)function. - Tag the resource's
httpapioperations withauthz.Require(<action>[, idParam])(orauthz.Public()for an unauthenticated operation). - Add the slice's
Contribution(...)to the slice list passed toauthz.Newininternal/app/app.go(alongsidetodoauthz.Contribution(repo)).
At runtime there is one merged PolicySet over one shared entity space, so a
policy in one slice can reference shared principal roles (principal in Role::"admin") or another slice's entities. Cross-cutting rules and shared
principal/role types live in the base package's base.cedar.
The API is rate limited per client out of the box (--rate-limit-enabled,
default true). The limiter is a Huma middleware installed before
authentication, so an over-limit request is rejected with 429 Too Many Requests before it reaches the credential store — protecting the auth path and
database from anonymous floods. The infrastructure routes (/healthz,
/readyz, /metrics) bypass Huma and are never limited.
Requests are keyed by client IP (the spoof-safe IP the
--trusted-proxy-header logic resolves), allowing a burst of
--rate-limit-burst and a sustained --rate-limit-rps per second (a token
bucket). A throttled response is RFC 9457 application/problem+json and carries
a Retry-After header (whole seconds).
The shipped limiter is in-process (golang.org/x/time/rate), with per-key
buckets evicted after an idle period to bound memory. That is the right default
for a single instance; behind multiple replicas a shared backend keeps the limit
global. The ratelimit.Limiter port is the seam: implement Allow over a store
such as Redis and wire your adapter in internal/app/app.go — the middleware and
key function are unchanged. To limit authenticated callers instead of IPs, swap
the key function (adapterhttp.ClientIPKeyFunc) for one that reads the principal.
The IETF RateLimit header fields (
RateLimit/RateLimit-Policy) are still a draft and map only loosely onto a token bucket, so the template advertises the limit with the stableRetry-Afterheader and leaves those headers as a documented enhancement.
Distributed tracing is OpenTelemetry-based and
opt-in (--tracing-enabled, default false) because it needs an external
collector. When enabled, the server exports spans over OTLP/HTTP and is
configured entirely through the standard OTEL_* environment variables — there
are no bespoke endpoint or sampler flags:
TEMPLATE_GO_API_TRACING_ENABLED=true \
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 \
OTEL_SERVICE_NAME=template-go-api \
OTEL_TRACES_SAMPLER=parentbased_traceidratio OTEL_TRACES_SAMPLER_ARG=0.1 \
./bin/template-go-api serve --database-url ...What is instrumented out of the box:
- Inbound HTTP — every request is a server span (
otelhttp) that extracts W3C trace context for propagation. Spans are named by operation (for exampleget-todo) for low cardinality. The infrastructure routes (/healthz,/readyz,/metrics) are excluded so health checks and scrapes do not flood the backend. - PostgreSQL — each query is a child span (
otelpgxon the pool), so a trace shows the SQL under the request that issued it.
service.name/service.version default to the app name and build version and
are overridable via OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES. The tracer
provider is flushed on graceful shutdown. To trace your own domain logic, start
child spans with the global tracer (otel.Tracer(...)) inside the service layer.
Unit tests sit beside the code and use Testify
(assert / require). Repository doubles are mockery-generated testify mocks,
committed under internal/todo/mocks and drift-guarded like the sqlc layer:
moon run root:mockery # regenerate internal/todo/mocks from the portsmoon run root:mockery-check (part of root:check) regenerates into a throwaway
directory and fails if the committed mocks are stale, so they can never drift from
the interfaces. Add a port to .mockery.yaml, then regenerate and commit. Use the
generated mock for interaction and error-injection assertions — see
internal/todo/service_test.go.
For tests that need a real, stateful store end to end — such as the HTTP
functional tests that create a todo and read it back — a small in-memory fake
lives in internal/todo/todotest, kept deliberately separate from the generated
mocks. The container-backed integration tests cover the
PostgreSQL adapter against a real database.
The server follows pragmatic hexagonal (ports & adapters) layering: the domain core depends on nothing in the adapters, and dependencies point inward.
cmd/template-go-api/ thin main; builds the Cobra root and executes
internal/
cli/ serve / version / openapi / migrate commands, Viper wiring
config/ server runtime config (flags + TEMPLATE_GO_API_* env)
todo/ domain: entity, Repository port, Service (the example)
httpapi/ inbound transport: the todo resource's DTOs, mapping, handlers
postgres/ outbound adapter: PostgreSQL Repository (pgx + sqlc)
queries/ hand-written sqlc queries
sqlc/ generated, committed, drift-guarded query layer
authz/ the todo authz slice: policy.cedar, actions, fact resolver
mocks/ generated testify mock of the Repository port (mockery)
todotest/ in-memory Repository fake for tests
authz/ base authz engine: Cedar Authorizer, deny-by-default middleware,
Require/Public declarations, Principal/Authenticator seam, base.cedar
apikey/ the shipped API-key Authenticator + PostgreSQL APIKeyStore
adapter/ shared, cross-domain infrastructure (not domain-specific)
http/ generic transport: chi router, middleware, RFC 9457 errors,
/healthz /readyz /metrics, OpenAPI export, Registrar seam
postgres/ connection pool (Connect) + goose migrate library
migrations/ embedded goose migrations (also sqlc's schema source)
observability/ slog logger, request logging, Prometheus metrics
logctx/ carries the request-scoped logger on the context
app/ composition root: wires everything and runs the server
integration/ container-backed integration tests (build tag: integration)
compose.yaml day-one local stack: postgres + migrate + seed + api
hack/sql/ *.sql seeds applied to the Compose database (local dev)
docs/ MkDocs site; docs/docs/openapi.yaml is the exported spec
sqlc.yaml sqlc generation config (repo root)
.mockery.yaml mockery generation config (repo root)
Replace or extend the todo example by following the same seams:
Each resource owns its code under internal/<resource>/: the domain core at the
package root, with its adapters nested beneath it.
- Add a domain package
internal/<resource>(entity +Repositoryport +Service), mirroringinternal/todo. - Implement the port in a nested adapter — mirror
internal/todo/postgresfor a PostgreSQL-backed datastore (see Persistence for the sqlc/goose workflow). sqlc generates one package persql:block, so add a secondsql:entry insqlc.yamlfor the new resource and update the literal paths in thesqlc-check,mockery, andmockery-checktasks (moon.yml) so the new generated/mocked packages stay drift-guarded. - Add a transport adapter
internal/<resource>/httpapi(DTOs, domain mapping, error translation, and aRegisterfunction), mirroringinternal/todo/httpapi. - Add an authz slice
internal/<resource>/authz(policies, actions, fact resolver) and tag thehttpapioperations withauthz.Require/authz.Public, then merge itsContributionininternal/app/app.go. See Authorization — deny-by-default means an untagged operation is rejected. - Add one
Registercall inregisterResourcesininternal/app/app.go, onto the/v1version group (see API versioning).
Shared, cross-domain infrastructure needs no changes: the generic transport in
internal/adapter/http and the connection pool / migrations in
internal/adapter/postgres. Because each resource's postgres package shares its
name with that shared infra (and, across resources, with each other), import the
per-resource adapters with aliases in app.go — as the todopostgres import
already shows. After changing the API, run moon run root:openapi to refresh the
committed spec (CI fails if it drifts). If you back the resource with PostgreSQL,
also add its readiness check to the Readiness slice in internal/app/app.go so
/readyz reflects it.
Resource routes are served under a URL version prefix (/v1); the operational
endpoints (/healthz, /readyz, /metrics, /docs, /openapi.*) are not part
of the versioned contract and stay at the root. The prefix is applied once, in
registerResources (internal/app/app.go), via Huma's group seam:
v1 := huma.NewGroup(api, "/v1")
httpapi.Register(v1, todoService)A huma.Group is a huma.API, so a resource's Register is identical whether
it mounts on a version group or the root API — and the authz middleware installed
on the root API is inherited by every grouped route. Each resource adapter keeps
declaring unprefixed paths (Path: "/todos"); the group prepends /v1, so the
running router and the committed OpenAPI document both report /v1/todos.
Introduce a breaking revision by adding a sibling group and registering the
changed resources on it; unchanged resources keep registering on v1:
v1 := huma.NewGroup(api, "/v1")
v2 := huma.NewGroup(api, "/v2")
httpapi.Register(v1, todoService) // unchanged endpoints stay on v1
todohttpapiv2.Register(v2, todoService) // only the revised ones move to v2Both versions then serve side by side and share one OpenAPI document. URL-path versioning is the default a reference template should teach: it is discoverable, cache- and proxy-friendly, and explicit in both the URL and the spec.
The MkDocs site publishes to GitHub Pages at
https://meigma.github.io/template-go-api/, including a generated
API Reference rendered from the
OpenAPI spec. Build it locally with moon run docs:build or preview with
moon run docs:serve.
Moon is the standard task front door:
moon run root:format
moon run root:lint
moon run root:build
moon run root:test
moon run root:check # the aggregate gate CI runs via `moon ci --summary minimal`Persistence- and testing-related tasks (see Persistence and Testing):
moon run root:sqlc # regenerate the committed sqlc query layer
moon run root:mockery # regenerate the committed testify mocks
moon run root:migrate -- up # apply migrations (pass --database-url after --)
moon run root:test-integration # container-backed adapter tests (needs Docker)root:check already runs sqlc-check and mockery-check (drift guards for the
generated query layer and mocks) alongside the formatter, linter, build, tests,
and OpenAPI drift guard.
The included Dockerfile builds a static Linux binary and copies it into a non-root distroless runtime image. The default entrypoint runs the server:
docker build --target test .
docker build -t template-go-api:dev .
docker run --rm -p 8080:8080 \
-e TEMPLATE_GO_API_DATABASE_URL='postgres://<user>:<password>@host.docker.internal:5432/<db>' \
template-go-api:devThe server requires a database, so pass TEMPLATE_GO_API_DATABASE_URL pointing at
a PostgreSQL reachable from inside the container — on Docker Desktop,
host.docker.internal resolves to a database running on the host (see
Running with PostgreSQL). For a fully wired
API + PostgreSQL run, use the Compose stack instead.
The Dockerfile pins the builder and runtime images by digest and verifies that
the selected Go builder image matches .go-version. When bumping Go, update
.go-version and the builder FROM tag/digest together.
Release builds can pass the same binary metadata injected by GoReleaser:
docker build \
--build-arg VERSION="$(git describe --tags --always --dirty)" \
--build-arg COMMIT="$(git rev-parse HEAD)" \
--build-arg DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t template-go-api:dev .The default CI workflow keeps permissions minimal, pins external actions, disables checkout credential persistence, and delegates checks to Moon.
It uses GitHub-hosted dependency caches for Go, golangci-lint, and uv download artifacts while leaving Moon remote caching as an optional follow-up for repositories that need a shared task-output cache.
The docs workflow builds the MkDocs site on pull requests and deploys docs/build to GitHub Pages from the default branch.
The scheduled security scan workflow builds the local container image weekly, scans it for high/critical fixed vulnerabilities, and uploads SARIF results to GitHub code scanning.
Dependabot covers GitHub Actions, Docker base images, the root Go module, and the docs uv project.
The build CLIs are pinned by version through Proto and their downloads are integrity-verified: golangci-lint, goose, and mockery verify against their publishers' checksum files (checksum-url), while sqlc — which publishes no checksums — is verified against a repository-committed per-platform digest (.moon/proto/sqlc.sha256) by the sqlc-verify task before it runs. Repo-pinning the other three was considered and deliberately left out: they already verify against upstream checksums, so committing per-platform digests would duplicate that control and add maintenance on every version bump.
Repository settings live in .github/repository-settings.toml.
They default to immutable releases, private vulnerability reporting, signed commits, squash-only merges, GitHub Pages workflow publishing, and protected tags.
Release automation is enabled for the template application so this repository proves the full binary and container release lifecycle before generated projects inherit it.
Repositories generated from the template should update the release app credentials, package names, asset patterns, container image name, and ghd.toml signer workflow before cutting their first release.
The release path is:
- Release Please opens and maintains the release PR.
- Release Please creates a draft GitHub release and tag after merge.
- Release Dry Run rehearses the GoReleaser binary path and native-runner Docker container build path on pull requests.
- GoReleaser builds binaries, checksums, and SBOMs without publishing directly.
- The release workflow uploads assets to the draft release and creates a GitHub-hosted attestation for
checksums.txt. - The release workflow builds amd64 and arm64 container images on native GitHub-hosted runners, publishes
ghcr.io/meigma/template-go-api:vX.Y.Zas a multi-platform manifest, attaches BuildKit provenance and SBOM metadata, and creates a GitHub-native attestation for the manifest digest. - A human inspects the draft release before publication.
The root ghd.toml matches the default GoReleaser output so generated projects can be installed with ghd once the release workflow runs.
See CONTRIBUTING.md for contribution guidelines, local setup expectations, and pull request workflow.
See SECURITY.md for supported versions and the private vulnerability reporting path.
Add the repository license before publishing a project generated from this template.