Status: beta, in production. Toon runs in production on ZAD and is still a beta: the design and the interface can change, as the disclaimer below says.
Toon is a platform that lets government organisations (among them MinBZK and the Nederlandse Digitale Dienst) publish and share static sites easily: publicly, behind SSO Rijk, with invitees by email address, or through a secret link. Every pull request can get a preview of its own.
Read this before you use anything here.
- Beta, in production. Toon runs in production for real users, as a beta. There is no support, no service level and no guarantee of any kind. The HTTP contract, the URL design, the database schema and the configuration can change without notice and without a migration path.
- Not independently reviewed. No external security review, no
penetration test, no audit. The security design is written down
(
docs/security.md) and covered by tests, which is not the same as being verified by somebody else. Do not put confidential data in an instance you run. - Built for one environment. The target is ZAD (Zelfservice Applicatie Deployment) with the Keycloak of that platform, which brokers to SSO Rijk. Everything environment specific is configurable, but no other platform and no other OIDC provider has been tried. If you run it elsewhere, you are the first.
- Reporting. Security issues go through
a private security advisory
or to
digigilde@rijksoverheid.nl, never through a public issue;SECURITY.mdhas the procedure. Everything else belongs in the issue tracker of this repository. - Licence. EUPL-1.2 (see
LICENSEandpubliccode.yml). That licence grants the software as is, without warranty of any kind and without liability, to the extent the law allows.
Toon is not an archive. It deletes old live versions every night and
lets administrators delete sites, so a published page can disappear.
The publishing organisation keeps its own records under its selection
list (Archiefwet). See §8 of docs/publishing.md.
The name comes from the Dutch word tonen, to show.
Toon shows your page or site to whoever may see it. That can be everyone,
or only a small group.
Toon used to be called Plak. §9 of docs/publishing.md says how to move over.
Toon is built for our own use, and therefore runs on the combination we use ourselves: deployment on ZAD (Zelfservice Applicatie Deployment, self-service application deployment) and login through that platform's Keycloak, which in turn brokers to SSO Rijk. It is not a product offered separately from that environment; everything environment specific is configurable, though, so another OIDC provider or another platform is not ruled out.
- Group: a collaboration unit (for example
team-aurora,fin). Holds sites and members. Groups do not nest. - Site: the published thing, with a live version and previews (comparable to the GitLab/Vercel model).
- Version: an immutable file tree, with target
liveorpreview. Besides the current live version, the most recent previous live versions stay available to roll back to (five by default,TOON_LIVE_VERSIONS_KEPT; a site admin can set another number per site,0keeps them all); a nightly job removes older ones. - Preview: a named temporary variant per ref (for example
pr-42), pointing at a version with targetpreview.
Access is a property per site (previews inherit it, with an optional override per preview): one base, plus two extras that let more people in.
The base is one of:
- Public: everyone.
- SSO Rijk: anyone who signs in with SSO Rijk.
- Site team: logged in and an active member with a role on the site or on its group.
- Nobody: lets no one in by itself, only the extras do.
The extras, each on or off beside the base:
- Secret link: access through
?key=selector.verifier, without logging in. - Invitees: logged in and the sub or verified email address is on the site's invitee list.
Viewing and administering are separated: a login only to look at
something creates no member record. Administering (creating sites,
uploading, setting access) does require an activated member record; see
docs/security.md for the full access and security logic.
Content owns the top level of the content host. Everything belonging to
the platform lives under the single segment -, which is not a valid
slug and so can never collide with a group. The admin SPA sits on the
root of the admin host, without a path prefix. See docs/design.md §4.
| Path | Meaning |
|---|---|
/{group}/{site}/... |
live content (content host) |
/{group}/{site}/_preview/{ref}/... |
preview content |
/{group}/{site}/_version/{version-id}/... |
view an old version (site team only) |
/ (admin host) |
the SPA: landing page or site overview |
/{group} |
group page (admin host). Other tabs: /{group}/-/members, /{group}/-/settings |
/{group}/{site} |
site detail (admin host). Other tabs: /previews, /versions, /access, /members, /deploy, /settings |
/-/groups, /-/platform, /-/sessions, /-/profile |
platform pages (admin host, /-/platform only for platform administrators) |
/-/privacy, /-/accessibility, /-/about, /-/whats-new |
platform pages (publicly accessible, no login) |
/cli-link |
where toon login sends you to approve a CLI session |
/-/login, /-/oauth2/callback, /-/logout |
OIDC login, the redirect URI and logout (POST on the admin host) |
/-/api/v1/... |
JSON and deploy API (admin host only) |
/-/api/docs |
API documentation (OpenAPI, self-hosted UI, publicly readable) |
/ (content host) |
302 to the admin host, whose landing page says what Toon is |
git clone <this-repo>
cd toon
./dev/up.shThis starts the full stack (nginx + app + PostgreSQL + mock OIDC)
through Podman compose on port 8080. The stack has two hosts and
separates them on the Host header: beheer.toon.localhost for
the admin and toon.localhost for content. On most systems both resolve
to 127.0.0.1 by themselves; if they do not, put them in /etc/hosts.
Log in through http://beheer.toon.localhost:8080/-/login; the mock
OIDC server logs you in automatically as a preconfigured dev
administrator. http://localhost:8080 does not work: an unknown host
gets a 400.
Install the commit hooks once per clone with uvx pre-commit install;
the CI job pre-commit runs the same set over the whole tree.
See docs/local-development.md for prerequisites, the compose
topology, justfile recipes and the Testcontainers/Podman socket
pattern.
The CLI lives in cli/, a uv project of its own. Install it as a tool
and the toon command is on your PATH:
uv tool install "git+https://github.com/DigiGilde/toon@beta#subdirectory=cli"That is the latest: beta is the default branch, so uv tool upgrade toon
picks up whatever has landed on it. Releases are tagged, so you can pin one
instead:
uv tool install "git+https://github.com/DigiGilde/toon@<tag>#subdirectory=cli"<tag> is the tag of a release
from v2026.11.2.1 on. Earlier tags install the old plak command. What a
branch gives up is the guarantee that tomorrow installs what you tested today.
The repository is public, so either form clones without credentials.
toon is also the command of the CLI of the TOON data format
(Token-Oriented Object Notation). If toon --version does not print
toon-cli, that other program comes first on your PATH.
Then log in once and publish:
toon login
toon publish ./dist --site team-aurora/website
toon publish ./dist --site team-aurora/website --preview pr-42Without --host the CLI talks to the DigiGilde instance,
https://beheer.toon.rijks.app. For another instance, log in once with
toon login --host <admin origin>: every command then uses that host
until you log in somewhere else. --host or TOON_HOST overrides it
for a single command.
toon login uses the OAuth 2.0 Device Authorization Grant: it shows a
code, you confirm it in the admin, and the session belongs to your user
account, for every directory. The tokens go into the system keyring
(macOS Keychain, Secret Service on Linux, Windows Credential Manager).
Where there is no usable keyring, or with --insecure-storage, they go
into ~/.config/toon/hosts.json (mode 0600; %APPDATA%\toon\hosts.json
on Windows) and toon login says so.
That file also remembers the host you last logged in to; set
TOON_CONFIG_DIR or XDG_CONFIG_HOME to move it. An old .env.plak
from an earlier version is no longer read: log in again and delete it.
No site yet? Create the group and the site from the terminal too; you
become admin of both. The access flags are optional (--access,
--secret-links, --invitees); a site you create without them follows
its group's default access:
toon group create team-aurora --name "Team Aurora"
toon site create team-aurora/docs --title "Documentation" --access ssoTo publish from CI, link the repository to the site; as site admin. For
a GitHub repository the IDs come from your own gh login, so a private
repository links too:
toon site link team-aurora/docs minbzk/website --live-branch mainIt prints the site id the workflow names beside the site (site-id in
the action, --site-id or TOON_SITE_ID for the CLI).
Deleting groups and sites, unlinking and managing members stays in the admin.
From a checkout of this repository the same commands run without installing anything:
uv run --project cli toon login
uv run --project cli toon publish ./dist --site team-aurora/websitedocs/publishing.md has the full story: the base path contract for
your build, publishing from CI with the publish action, the curl
fallback and the error codes.
The composite action in actions/publish/ publishes from GitHub
Actions or Forgejo Actions without a secret: the workflow proves who it
is with an OIDC ID token, and Toon checks that token against the
repository linked to the site. Link the repository first, on the
Deploy tab of the site in the admin; that tab also shows a
ready-made workflow for the site. The core of it, a live deploy on every
push to main:
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<commit-sha>
- run: npm ci && npm run build
env:
TOON_BASE_PATH: /team-aurora/website/
- uses: DigiGilde/toon/actions/publish@<commit-sha>
with:
site: team-aurora/website
site-id: <site-id>
dist-path: ./distThe site-id, on the Deploy tab of the site, makes the token valid for
that one site: an old or mistyped site: can then never publish
anywhere else. Pin the action to a commit SHA, not to a branch or a tag.
The full workflow in docs/publishing.md adds a preview per pull request
and its clean-up, and shows the Forgejo variant.
The Claude Code plugin in plugin/ lets Claude do the publishing with
the rules of this project in hand: a preview by default, live only when
you ask for it, and a toon login that you approve yourself. It uses
the toon CLI above, so install that first. Then, in Claude Code:
/plugin marketplace add DigiGilde/toon
/plugin install toon@toon
Ask for it in the directory of your site, for instance "zet deze map
online op Toon". Auto-update is off for this marketplace unless you turn
it on (/plugin, tab Marketplaces). To get a newer version by hand,
refresh the marketplace with /plugin marketplace update toon, then pick
Update now on the plugin in the Installed tab of /plugin, or
run claude plugin update toon@toon in your shell. docs/skill.md
covers what the skill does and how to run its evals.
Toon runs in production on ZAD (Zelfservice voor Applicatie Deployment): no Kubernetes manifests, but one declarative project file that the platform manages. Toon is one component there, because the app serves content, the admin SPA and the host separation itself.
- Two web addresses on that one component, through
publish-on-webwith a dotteddomain-formatandroot-component:beheer.toon.<domain>andtoon.<domain>. Both have to be approved by the platform team; without approval the rollout falls back to the cluster address. - PostgreSQL and the content volume come as ZAD services, with the
volume's size declared in the project file. How full the volume is
shows in
/-/healthz, in the log and in the platform administrator's view on "Platformbeheer" (/-/platform) (docs/deploying-on-zad.md§5, §9). - Each site's history on that volume is bounded: a 500 MiB quota per
site (
TOON_SITE_MAX_BYTES) and a nightly cleanup of live versions beyond the current one and the ones before it that the site keeps (five by default,TOON_LIVE_VERSIONS_KEPT, or the site's own number). - Secrets go in as user env vars (
zadctl env), OIDC runs through the ZAD Keycloak, and migrations run through a job in the portal or at container start: ZAD has no notion of a Job.
deploy/toon-project.example.yaml is the project file with comments,
docs/deploying-on-zad.md the step-by-step guide from nothing to
running plus the open questions for the platform team.
.github/workflows/ holds the CI and deploy workflows. The checks run on
every commit. A push to beta deploys nowhere: production follows the
release tags (docs/releasing.md).
docs/design.md: the numbered design. Every "spec §7" or "behaviour requirement 6" in the code points at a section there.docs/publishing.md: how to publish a site (base path contract, thepublishaction, curl fallback, previews and_version), and what to change when you move over from Plak.docs/skill.md: the Claude Code plugin inplugin/: installing it, what the skill does, and running its evals.docs/security.md: the living security checklist.docs/privacy.md: draft starting point for the DPIA, personal data processed and open privacy items.docs/local-development.md: dev stack, justfile, Testcontainers.docs/deploying-on-zad.md: the step-by-step guide for the rollout on ZAD.docs/releasing.md: how releases are cut fromCHANGELOG.md, the CalVer tags and how to write an entry.
backend/: Python 3.15, FastAPI, SQLAlchemy (async, asyncpg), Alembic, joserfc (JWT and JWKS for OIDC and CI tokens), pydantic-settings, brotli (text files are stored and sent compressed).frontend/: Vue 3 (runtime-only build), Vite,@nldd/design-system.cli/: the publishing CLI, a uv project of its own that installs thetooncommand. Its dependencies are httpx and keyring, and it shares no code with the backend.cli/tests/is its suite (just test-cli).actions/publish/: the composite action for GitHub Actions and Forgejo Actions that calls that CLI.plugin/is the Claude Code plugin for the same job, with thetoon-publishskill and its evals;.claude-plugin/marketplace.jsonoffers it for installation.- The app serves content and the admin SPA itself (
FileResponsewith Range, ETag/304 and the fixed header set) and separates the admin and content host on theHostheader; nginx is only a dumb reverse proxy in the dev stack. There is no X-Accel-Redirect. - PostgreSQL 16 as the content model database; the content volume is a filesystem content store.
- Containers through Podman (
Containerfile,podman compose).
EUPL-1.2, see LICENSE.
Copyright Ministerie van Binnenlandse Zaken en Koninkrijksrelaties.
See CONTRIBUTING.md to contribute and CODE_OF_CONDUCT.md for how we work together.