A runnable, open-source Go foundation for multi-tenant SaaS. Clone it, run
one command, and get tenant isolation, users, authentication, API keys, audit,
content, notifications, tenant branding, health, and a real operator console in
one process.


Run the stable starter
No clone required. @latest resolves the newest release through the Go module
proxy, so this command does not go stale:
go run github.com/septagon-oss/platformkit@latest
Clone instead when you want the source to read, extend, or verify:
git clone https://github.com/septagon-oss/platformkit
cd platformkit
go run .
On a fresh database the default is deliberately predictable: the ten-module
starter from pk-apps, SQLite, and a loopback-only listener. Point
database.driver at postgres when you are ready to deploy.
============================================================
PlatformKit OSS
listening: http://127.0.0.1:8080
admin UI: http://127.0.0.1:8080/admin
health: http://127.0.0.1:8080/healthz
OpenAPI: http://127.0.0.1:8080/openapi/extensions.json
local tenant: tenant_local
local login: operator@local.test / local-development-only
modules: 10 composed (...)
============================================================
Open http://127.0.0.1:8080/. The public landing page explains the running
surface without leaking credentials. The terminal prints the development login;
/admin presents a responsive, scope-protected operator workspace with typed
forms, useful tables, lifecycle actions, empty/error states, and mobile
navigation.
The fresh-database local development bootstrap is:
- Tenant:
tenant_local
- Email:
operator@local.test
- Password:
local-development-only
An upgraded database may retain previously released tenant and user IDs so
downstream module rows are not orphaned. Its startup banner prints the actual
tenant ID and resolved email to use; the visible labels and development
password are still neutralized.
Those credentials are for local development. A configured or non-development
deployment fails closed without seed.admin_password, never reasserts a changed
production password, and never prints that password. PORT=8090 go run .
changes the port while staying on loopback. Listening on a network interface
requires an explicit address such as
PK_HTTP_ADDR=0.0.0.0:8080 go run ..
The CLI
The binary is a small cobra CLI. Running with no subcommand serves, so the
quickstart above never changes. Configuration precedence, lowest to highest:
built-in defaults → config.yaml → environment variables → flags.
go run github.com/septagon-oss/platformkit@latest --port 9090 # loopback port
go run github.com/septagon-oss/platformkit@latest new app acme # scaffold your own application
go run github.com/septagon-oss/platformkit@latest config init # commented config.yaml template
go run github.com/septagon-oss/platformkit@latest version --json
go run github.com/septagon-oss/platformkit@latest modules --json
go run github.com/septagon-oss/platformkit@latest openapi > openapi.json
modules and openapi compose the real application against a throwaway
in-memory database, so they never create or migrate ./pk.db. Serve flags:
--addr, --port, --config, --env, --db-dsn, --admin-email, and
--admin-password (prefer the PK_ADMIN_PASSWORD environment variable so the
secret stays out of the process list). platformkit --help documents all of
it, including the environment variables.
Make it your product
new app writes a Go application that boots this starter and is ready for your
own modules; new module adds one.
platformkit new app acme && cd acme
platformkit new module invoice
make verify # go vet + go test -race, including the generated module's tests
go run . # your app, your name on the console
A scaffolded application carries a container image, a Makefile whose verify
target is the same gate this project holds itself to, a config.example.yaml
that keeps secrets in the environment, and an agent pack (AGENTS.md,
llms.txt) that teaches an AI coding agent the rules for extending it safely.
Generated modules register themselves, so adding one never edits main.go. Each
ships tenant-scoped queries, per-route scope checks, canonical entity IDs,
append-only migrations, and a test that fails the moment tenant isolation
breaks — the same contract a hand-written module must meet.
Choose a database
SQLite is the default and needs no setup. For a real deployment, point the
driver at Postgres:
database:
driver: postgres
dsn: "postgres://user:pass@host:5432/db?sslmode=require"
The binary registers both drivers, so the engine is configuration, not a
rebuild. Nine of the ten module stores have a real Postgres adapter, and both
adapter sets pass the same store conformance suite, so a missing tenant
predicate fails a test on either engine. The exception is the new branding
module: its Postgres adapter has not landed yet, so a Postgres deployment
composes nine modules (stock chrome) and refuses to boot if branding seed
values are configured — the gap is loud, not silent. Supply the administrator password through
PK_ADMIN_PASSWORD; it is applied after config.yaml loads, so the secret stays
out of your config, git history, and image layers.
Use the API
Authentication resolves a server-owned tenant and subject. Built-in resources
require explicit <resource>:read or <resource>:write scopes; the seeded
administrator has full access. API keys cannot acquire interactive admin or
console:access capabilities.
# Use the exact values printed in this database's startup banner. These are the
# fresh-database defaults; an upgraded database can retain an older tenant ID.
TENANT_ID=tenant_local
ADMIN_EMAIL=operator@local.test
ADMIN_PASSWORD=local-development-only
# Log in. The response contains a session ID.
curl -s -X POST http://127.0.0.1:8080/api/v1/auth/sessions \
-H 'Content-Type: application/json' \
-d "{\"tenant_id\":\"$TENANT_ID\",\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASSWORD\"}"
# Send that ID as a bearer token.
curl -s http://127.0.0.1:8080/api/v1/tenants \
-H 'Authorization: Bearer YOUR_SESSION_ID'
Useful runtime routes:
| Route |
Purpose |
Access |
/ |
Product and runtime landing page |
Public |
/admin |
Schema-aware operator console |
admin + console:access |
/healthz, /live, /ready |
Health and orchestration probes |
Public |
/metrics |
expvar process and module metrics |
metrics:read or admin |
/openapi/extensions.json |
Validated OpenAPI 3.1 extension operations |
Public |
/api/v1/tenants |
Read/update/delete the caller's tenant; provisioning is out of band |
tenants:read/write or admin |
/api/v1/users |
User management |
users:read/write or admin |
/api/v1/api-keys |
Scoped machine credentials |
api-keys:read/write or admin |
/api/v1/audit-events |
Append-only audit query |
audit:read or admin |
/api/v1/content |
Stored content and publish lifecycle |
content:read/write or admin |
/api/v1/notifications |
Stored in-app notification records |
notifications:read/write or admin |
All request bodies are capped at 1 MiB. Mutating JSON rejects unknown fields
and trailing values, malformed or negative pagination returns 400, and API
key scopes must be built in or declared by an application module. Anonymous
mutations are rejected, tenant identity comes from the verified credential
rather than request JSON, and process metrics are not public.
The generic starter does not invent end-user presentation. Creating a
notification persists a user-scoped record for the API/operator surface; it
does not display a navbar bell, toast, email, SMS, or push message. Creating or
publishing content persists its lifecycle state; it does not create a public
page, template, or URL. Those delivery and rendering choices belong in the
downstream application. See the
current runtime boundaries.
Build your product on top
This repository stays deliberately domain-neutral. Product-specific modules
belong in your application repository, where they can be changed or replaced
without forking PlatformKit.
The supported starterapp.WithModules seam composes application-owned modules
into the same SQLite pool, module catalog, identity perimeter, admin and health
registrars, request limits, and OpenAPI discovery as the built-ins:
err := starterapp.Run(
ctx,
starterapp.DefaultConfig(),
starterapp.WithModules(yourModule),
)
Start with the generic
pk-apps/reference/custommodule
reference, then keep your domain model, migrations, routes, and policies in the
repository that owns the product. The foundation remains reusable whether the
result is a CRM, marketplace, internal tool, booking system, or another SaaS.
The reference declares application API-key scopes, enforces them on every
route, uses append-only embedded migrations, derives identity from the
authenticated principal, and tests tenant isolation and strict inputs.
When you need to see the seam carry a full domain rather than a minimal one,
pk-apps/reference/polls
adds a lifecycle, an audit outbox committed atomically with each mutation,
signed anonymous voter identity, per-network throttling, /metrics counters,
and a public browser surface beside the JSON API.
What is included
- Tenants — isolation in stores and request identity.
- Users — tenant-scoped records and password lifecycle.
- Authentication — browser sessions and bearer-session support.
- API keys — one-time plaintext display and explicit machine scopes.
- Audit — append-only operational events.
- Content — draft and publish lifecycle.
- Notifications — tenant/user-scoped in-app messages.
- Branding — tenant logo and palette with WCAG-corrected derivation; themed chrome and first-login setup.
- Admin — a responsive, schema-aware reference console.
- Health — module health plus runtime liveness/readiness.
Verify before shipping
make verify # format, vet, staticcheck, tests, race, and build
make coverage # atomic coverage profile and function report
make security # govulncheck + gosec
make release-check # all of the above
GitHub Actions runs the verification and coverage gate on every pull request,
plus dependency review, CodeQL, govulncheck, and gosec. The module has no local
replace directives, so a clean clone exercises the same public dependency
graph a user gets.
Boundaries and expectations
- This is not a no-code product. Extension code is Go.
- It is not a Rails/Django-style MVC framework or ORM.
- SQLite is the zero-setup default for local development and small single-node
deployments; set
database.driver: postgres for the production profile. Both
adapter sets pass the same store conformance suite, so the engine is a
configuration choice, not a rewrite.
- The reference admin is a useful operator surface, not an enterprise policy
engine.
- PlatformKit is pre-1.0. Pin versions and expect deliberate API evolution.
- Modules are optional. Take the starter, select another composition, or build
your own through the same ports.
- Notification email/SMS/push delivery, a navbar inbox/toast UI, and public
content rendering are downstream features, not hidden starter behavior.
- The frontend stack is Go end to end.
pk-design publishes the canonical
theme, tw compiles typed utility classes and emits their CSS, and pk-ui
renders accessible components — no Node, no Tailwind build, no bundler.
There is still deliberately no Storybook gallery or Figma export here;
those belong to downstream distributions.
- Schemas are Go modules compiled into the binary, not collections defined at
runtime through the admin UI. If you want to add a field by clicking in a
dashboard, a runtime-collection backend such as PocketBase or Directus fits
better. PlatformKit trades that for multi-tenancy, scoped machine credentials,
an append-only audit trail, and module contracts the compiler checks.
- Content is stored and administered, not published. The built-in content module
gives you a tenant-scoped store, an API, and an operator console; it serves no
visitor-facing page. Public rendering is a downstream concern —
pk-apps/reference/polls
shows a module serving its own public page.
How the pieces fit
pk-core defines module and security contracts. pk-modules implements the
reference capabilities. pk-apps/pkg/starterapp owns the one canonical starter
composition. This repository is the domain-neutral public front door.
Modules depend on published interfaces, not one another's concrete
implementations. Downstream products extend the starter through published
contracts without placing their domain code in PlatformKit:
flowchart LR
core["pk-core\nmodule + identity contracts"]
modules["pk-modules\n10 reference modules"]
starter["pk-apps/starterapp\nstable composition"]
front["go run .\ndomain-neutral front door"]
product["your product repository\napplication-owned modules"]
core --> modules
modules --> starter
starter --> front
starter --> product
core -. "published contracts" .-> product
Static architecture asset: docs/architecture.svg
Repository family
Everything is pre-1.0: pin versions and expect deliberate API evolution.
The tiers say how each repository moves, not how finished it is. Released
set repositories move together — this front door pins an exact set that is
boot-tested as a whole, and their tags are cut in dependency order. Toolchain
repositories gate and generate code but are not linked into your binary.
Foundations move fastest; consume them through the released set unless you
are extending the design system itself.
| Repository |
Purpose |
Tier |
platformkit (this repo) |
Domain-neutral front door |
Released set |
pk-apps |
Canonical starterapp composition library and extension seam |
Released set |
pk-modules |
Reference business modules and admin |
Released set |
pk-core |
Module, dependency, identity, and runtime contracts |
Released set |
pk-shared |
Cross-repository vocabulary |
Released set |
pk-runtime |
Hosting and health primitives |
Released set |
pk-guard |
Composable go/analysis guardrails; the modules' verify gate |
Toolchain |
pk-tools |
Developer tooling — pk new module, pk explain |
Toolchain |
pk-testkit |
Conformance and flow testing |
Toolchain |
pk-design |
Design tokens, component contracts, WCAG contrast machinery |
Foundations |
pk-ui |
Component contracts, ARIA builder, and gomponents renderers |
Foundations |
tw |
Typed utility-class DSL and CSS emission for the design system |
Foundations |
styleengine |
Typed Go-native CSS construction, parsing, and sanitizing |
Foundations |
pk-client |
Client primitives for calling a PlatformKit API |
Foundations |
pk-docs |
Public architecture and operating guides — published site |
Docs |
Project links