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.
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@latestClone 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 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.jsonmodules 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.
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 consoleA 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.
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.
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.
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.
- 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.
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 aboveGitHub 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.
- 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: postgresfor 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-designpublishes the canonical theme,twcompiles typed utility classes and emits their CSS, andpk-uirenders 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/pollsshows a module serving its own public page.
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
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 |