The workshop planner for people who run workshops.
Plan the day, not the spreadsheet.
goodworkshop.org · Self-hosting · Documentation · Releases · Licence
▶ Watch the tour — 1:45: plan a day, hand it over, and let an AI client work inside the same agenda.
You know the moment: the client moves lunch, one exercise runs long, and suddenly every time on your agenda is wrong. GoodWorkshop takes that off your plate. Drag a block, and the day recalculates itself. Pin what must not move, park what you might still need, and walk into the room with an agenda that is always right, on your laptop and on your phone.
Two ways in, and the same product behind both — one codebase, one image.
|
goodworkshop.org — we run it, you plan. Nothing to install, nothing to keep up to date, backups and certificates taken care of. The cloud runs in the EU, operated by an Austrian company, with a data processing agreement under Art. 28 GDPR. Offered to businesses only. |
Free, commercial use included — on your own server. One container image and one Postgres, up in minutes with Docker Compose. No licence cost for planning and running the workshops you earn your money with. Your data never leaves your house. |
Whichever you pick, every agenda comes back out as Markdown whenever you want, and the MCP server is built into both.
- MCP-ready. Let Claude, ChatGPT, Gemini or any other MCP client draft and rework your agenda. It is a built-in MCP server, not an add-on.
- Lightweight. One container image and one Postgres. No external services, no tracking, no heavyweight setup. Up in minutes with Docker Compose.
- Free when you host it yourself. Commercial use included: plan and run the workshops you earn your money with, at no licence cost.
- Or let us run it. goodworkshop.org is the same product, hosted in the EU, for businesses that would rather not operate anything.
- Your data stays with you. Your server or ours, and every agenda is exported as Markdown whenever you want.
- Built for the room. Works on a phone, survives bad wifi, and lets your co-facilitator edit the same day live.
Released and in use. The current version is on the releases page; images are published to
ghcr.io/rolealpha/good-workshopfor amd64 and arm64.
Your agenda does the arithmetic. A workshop is made of days, and a day is made of blocks: check-ins, inputs, group work, breaks. Group them into clusters and reorder them by dragging. Every time that follows moves along with them. The header always shows how much is content, how much is breaks, and how far the day runs past the end you set.
What must not move stays put. Lunch at 12:30, the client's slot at 15:00: pin a start time and it holds while you rearrange everything around it. If something collides with a pinned block, the row tells you, for example overlaps the previous block by 20m. Nothing is quietly shortened behind your back.
Everybody knows who is on. Every block has one or more responsible people, shown as chips with initials right under the title. Pick a member of your team, or just type the name of the guest speaker or client lead who is not in the system. The print view, the Markdown export and your AI client all know who does what.
Cut without losing. Too much for one day? Park a block. It keeps its description and its material but stops counting toward the time. The parking area belongs to the whole workshop, so an exercise you cut on day one can go straight into day two.
Everything the room needs, right in the row. Social form (plenary, small groups, pairs, individual), the material to bring, and the fields each block type needs. You edit all of it where it stands. There are no dialogs, because a dialog gets between you and the agenda you are reading.
Your notes stay yours. Facilitation notes belong to the block and stay with you. The print view and the Markdown export leave them out by default, so the group gets the agenda and you keep the rest.
As many days as your workshop has. Every day has its own start, its own agenda and its own note. Add a day, name and date it, and switch between days with the tabs above the agenda.
Fifteen block types, ready to go. Check-in, input, group work, exercise, discussion, decision, energizer, reflection, break, lunch, buffer, next steps, check-out and more. Each one brings the fields it actually needs. Type in the picker and the list narrows as you go.
Type durations the way you think. 45, 45m, 1h, 1:30, 1h30, 90, or 1 Stunde,
1 heure, 1 hora in your own language. If GoodWorkshop cannot read something, it asks
instead of guessing.
Made for standing in front of a group. On a phone, the day turns into cards: body text never smaller than 16px, tap targets of 44px, and the section you are in stays pinned to the top. Dragging needs a long press, so a swipe still scrolls. Social form, material and the clock are one tap away.
Survives a bad room. The wifi drops out? The agenda says so, keeps accepting your changes and syncs them once the connection is back.
Plan together, live. Several people can edit the same workshop day at once. The presence bar shows who is there, and changes appear instantly for everybody and merge instead of overwriting each other.
Leaves the room with you. A print view that fits on paper, and a Markdown export for the protocol, the wiki or the follow-up mail, in the language of whoever exported it.
Find anything again. Workshops live in folders and carry tags, both optional. Folder, tag and search are part of the URL, so a filtered library is a link you can share.
Built for teams. An admin invites people by e-mail under Members. The owner of a workshop grants access under Access, to edit or to read. Share a whole folder and everything inside it comes along. Nobody can pass on more access than they have.
Share with clients who have no account. Invite any e-mail address to one agenda, read-only or with editing. They see that one agenda and nothing else from your installation. The link works until the last day of the workshop, and you can withdraw it at any time.
No passwords. Sign in with a magic link by e-mail, or with a passkey: Face ID, Touch ID or a hardware key. There is no password to forget, reuse or leak.
Speaks your language. German, English, French and Spanish: interface, e-mails, export and block types. Everybody picks their own language, and the text you write stays exactly as you wrote it.
Looks like you. Add your logo and accent colour under Branding. The light and dark shades are derived automatically.
Forgives mistakes. A deleted workshop goes to the bin, not away. It comes back with one click, and it is only really gone when somebody deletes it for good, by name.
GoodWorkshop is an MCP server. Claude Desktop, Claude Code or any other MCP client can do what the library and the day editor do: folders, workshops, tags, the bin, days and whole agendas, including responsible people and parked blocks. Ask for "a two-day strategy offsite with a SWOT in four groups" and watch it appear. Sharing and user administration stay in the app.
Create a token under AI Connection in the profile menu at the top right. The page prints the ready-made command or configuration for Claude Code, Claude Desktop, the Gemini CLI, Codex and Langdock, with your host and token already filled in. A token acts as the person who created it: it can never do more than that person, and it cannot touch user administration at all.
Clients that cannot hold a fixed token, such as ChatGPT's connectors or the connector UI in claude.ai, connect over OAuth. They discover your installation, register themselves, and you approve them once on a consent screen that says which client is asking and what it will be allowed to do. There is nothing to configure. The AI Connection page walks you through it for Claude, ChatGPT, Claude Code, the Gemini CLI and other clients, including two things the flow does not tell you: hosted clients need your installation to be reachable from the internet, and you should be signed in before you connect. A client connected this way may read and write workshops, exactly like a token with those two scopes.
How GoodWorkshop is built on the inside, and why, is in docs/architecture.md.
Everything from here on is about running GoodWorkshop yourself. If you would rather not operate a server, goodworkshop.org is the same product, hosted — and you can stop reading here.
- Docker with Compose v2 (
docker compose version), amd64 or arm64 - A hostname that resolves publicly to this server, and ports 80 and 443 free — Let's Encrypt needs both to issue the certificate
- No Postgres needed: the database runs inside the stack and is not reachable from outside
- No passwords to hand out: the stack generates the database secrets itself on first start
You need compose.yaml, Caddyfile and a .env:
git clone https://github.com/roleALPHA/good-workshop.git
cd good-workshop
cp .env.example .envThe releases are published to the GitHub Container Registry, and compose.yaml pulls the one
GW_VERSION names when the stack first comes up. latest always points at the newest stable
release (currently v0.8.40). Every release is also published under its own tag, without the
v (0.8.40) — set that instead to stay on one version until you decide to update. There is
nothing to build.
To run a state that carries no tag of its own, build it and give it the name compose.yaml
expects:
docker build -t ghcr.io/rolealpha/good-workshop:local .Then set GW_VERSION=local in the .env.
Three values are required; without them the stack does not start and says which one is missing:
GW_APP_URL=https://workshop.example.com # the address the app is reachable at
GW_HOSTNAME=workshop.example.com # the name in the certificate (profile `tls`)
GW_VERSION=latest # the newest stable release, or e.g. 0.8.40 to pin oneMail can wait. The first start does not need it: the setup screen shows your sign-in link
itself. Afterwards you set up mail delivery in the interface under Mail delivery — SMTP,
Microsoft Graph, or nothing. If you would rather keep it in the .env, set
GW_MAIL_TRANSPORT (smtp, graph, console or none) there; the environment then wins per
field, and the screen says which fields it has already claimed.
With GW_MAIL_TRANSPORT=smtp you also need SMTP_URL and SMTP_FROM, in the .env or in the
interface. If the SMTP_URL contains a password, it belongs in a file instead:
SMTP_URL_FILE=/run/secrets/smtp_url. An environment variable shows up in docker inspect, in
/proc/<pid>/environ and in every core dump.
Microsoft 365 without SMTP: many tenants have SMTP AUTH switched off — there
GW_MAIL_TRANSPORT=graph is not the more convenient way but the only one. You need an app
registration in Entra ID with the application permission Mail.Send (not the delegated
one) plus admin consent, and the mailbox to send from:
GW_MAIL_TRANSPORT=graph
GW_GRAPH_TENANT_ID=contoso.onmicrosoft.com # or the directory id
GW_GRAPH_CLIENT_ID=00000000-0000-0000-0000-000000000000
GW_GRAPH_CLIENT_SECRET_FILE=/run/secrets/graph_secret
GW_GRAPH_SENDER=workshop@contoso.comIf one of these is missing, the application says so at startup — not when somebody first
fails to sign in. With Mail.Send alone Graph can do nothing but send: the permission does
not allow reading mailboxes.
Decide the hostname now — changing it later invalidates every registered passkey. See Things that trip people up.
docker compose --profile tls up -dIn order: secrets generates the database passwords, db starts, migrate creates the
roles, checks the existing data, walks through the migrations and provisions the block types.
app starts only once migrate has come through cleanly, then caddy takes 80 and 443 and
fetches the certificate.
Once it runs, the healthcheck answers:
curl -fsS https://workshop.example.com/api/health
# {"status":"ok","checks":[{"name":"database","ok":true},{"name":"migrations","ok":true}]}A 503 is not a crash but the honest answer "this container cannot serve" — checks says
whether it is the database or the migration state.
A fresh installation announces itself in the log on every start, until somebody claims it:
docker compose logs app This installation has no administrator yet.
https://workshop.example.com/setup
Setup key: 7Qb3…
The key is valid until this process restarts.
Open that address, enter your e-mail address and the key, and the installation is yours. Everything after that — mail delivery, further people, branding — happens in the interface. Cannot find the key or the link? See Troubleshooting.
The key is the whole access control on that screen, and it is deliberate: a first-run page
that hands out the administrator account to whoever loads it is a takeover waiting for the gap
between docker compose up and you opening your browser. Whoever can read
docker compose logs app is the operator. The key lives in memory, so a restart issues a new
one, and the route disappears once an admin exists — an installation cannot be talked into a
second first run.
If mail is not configured yet, the sign-in link is shown on the page instead of being sent. That is the ordinary case at this point, and the reason this screen does not need mail to work.
Two older routes, for automation and for a broken first run
Through the CLI, which needs a shell on the server:
docker compose exec app node scripts/cli.mjs admin create \
--email you@example.com --first-name Anna --last-name BergerOr on the very first start: put GW_BOOTSTRAP_ADMIN_EMAIL=you@example.com into the .env
before the stack comes up. The link is then in docker compose logs migrate, is valid for an
hour and is printed exactly once — later starts do nothing, even if the variable stays. Useful
when an installation is provisioned by a script rather than by a person.
# 1. Back up. An upgrade without a backup is a bet.
scripts/backup.sh before-upgrade-$(date +%F).sql.gz
# 2. Fetch the new image. With GW_VERSION=latest that is all;
# with a pinned version, point GW_VERSION in the .env at the new tag first.
docker compose pull
# 3. Bring it up.
docker compose --profile tls up -dWhich tags exist is on the releases page.
migrate runs on every start and app waits for it — a container that would serve against a
schema it does not understand never comes up in the first place.
A read-only preflight runs before the migrations. It establishes whether the existing data meets the conditions a pending migration imposes. If it stops, the database is unchanged — there is nothing to roll back. The message names the affected rows, the query to look at them and the decision to be made.
To see what an upgrade would find, without upgrading:
docker compose run --rm migrate node scripts/preflight.mjsSpecifically when upgrading to this version. One migration requires that a block and a cluster belong to the workshop whose day they sit on. Existing installations may have rows where that is not true. The preflight finds them and names them one by one; where they belong is a question about content, which no script should guess.
An updater such as Watchtower replaces only the container it watches. The migrate service
never runs, and the new app would serve against the old schema. For that case app can run
the same chain itself before it starts — at a price: it then holds the superuser and gw_owner
passwords as well, and the role separation described above is gone for this container. Decide
that, don't drift into it.
A compose.override.yaml next to compose.yaml:
services:
app:
labels:
- com.centurylinklabs.watchtower.enable=true
environment:
GW_MIGRATE_ON_START: '1'
MIGRATION_DATABASE_URL: postgres://gw_owner@db:5432/goodworkshop
MIGRATION_DATABASE_PASSWORD_FILE: /run/db-secrets/gw_owner/password
ADMIN_DATABASE_URL: postgres://postgres@db:5432/goodworkshop
ADMIN_DATABASE_PASSWORD_FILE: /run/db-secrets/postgres/password
volumes:
- secret_postgres:/run/db-secrets/postgres:ro
- secret_gw_owner:/run/db-secrets/gw_owner:ro
watchtower:
image: nickfedor/watchtower:latest
restart: unless-stopped
command: --label-enable --cleanup --schedule "0 0 4 * * *"
volumes:
- /var/run/docker.sock:/var/run/docker.socknickfedor/watchtower rather than containrrr/watchtower: the original is no longer maintained
and speaks Docker API 1.25, which Docker 29 refuses — it then restarts in a loop and updates
nothing, without anything else noticing. A failed step keeps app from starting, exactly like
the migrate service; the log names the step. migrate still runs on a normal up — both are
idempotent, running twice costs nothing.
The database is the complete record, logos included — they are rows, not files. So a pg_dump
is enough:
scripts/backup.sh goodworkshop-$(date +%F).sql.gzThe script rather than the line by hand, because the line by hand creates a file even when
nothing was backed up: pg_dump writes its error to stderr and leaves stdout empty, and the
redirection created the target file long before that. What is left is an empty archive with
the right name and today's date. The script writes next to it first, checks that the dump ran
all the way to its closing line, and only then gives it its final name — yesterday's backup
stays untouched until it does.
The database passwords live in their own Docker volumes (secret_*) and are not in the
dump. A restore onto a new server does not need them either: secrets generates new ones and
migrate sets them on the roles.
One exception: the application key. It decrypts the mail credentials entered in the interface, and it is the only thing that can read values already in the database. So it belongs in the backup:
docker compose exec -T app cat /run/db-secrets/app/secret-key > goodworkshop-key.txtIf it is lost, the dump comes back with empty mail credentials. Everything else survives — workshops, members, branding — and the credentials get entered once more.
Backing up nightly. Three things belong in the archive, and only those three: the dump,
the application key and the .env. Not the database passwords — the stack generates those
afresh on every start.
cd /path/to/the/stack
./scripts/backup.sh "$TARGET/goodworkshop.sql.gz"
cp "$(docker volume inspect goodworkshop_secret_app --format '{{.Mountpoint}}')/secret-key" "$TARGET/"
cp .env "$TARGET/"The key comes from the volume here rather than through docker compose exec: a backup that
only succeeds while the application is running is missing on exactly the night something was
broken. The volume name carries the project name in front — that is the stack's directory
name, unless COMPOSE_PROJECT_NAME says otherwise; docker volume ls shows it.
If you are extending an existing backup run with GoodWorkshop: with set -e the whole run
stops when the dump fails — for the other services in it too. That is the right choice. A run
that quietly skips one service and still reports "done" is the road to an archive you trust
without it holding.
Check what is in the archive, not whether it exists. A failed dump produces a valid gzip
archive of 20 bytes — present, readable, empty. Looking for pg_dump's closing line costs one
line and actually answers the question:
gzip -dc goodworkshop.sql.gz | tail -c 400 | grep -c 'dump complete'Three scripts for the rest of it. A backup that lives on the machine it is a backup of is not one, a backup nobody has ever restored is a hope, and a backup job that stopped running is silent. Each of those is a script, and each is meant to be a systemd timer:
| Script | What it answers | Where it runs |
|---|---|---|
scripts/backup-offsite.sh |
is today's state somewhere else, encrypted | on the server, daily |
scripts/backup-verify.sh |
does it actually come back | on the server, weekly |
scripts/backup-freshness.sh |
is anything still being backed up at all | on a different machine, daily |
The third one is on a different machine on purpose: run it beside the thing it watches and the
watching stops with the host it was supposed to notice. It also works without the repository
password, and that is the case worth having: it then reads how old the newest file in the
repository's snapshots/ directory is, which answers "is anything still being backed up" completely.
Whether the repository itself is sound is what restic check answers in the backup run. A watcher
that needs no secret may run on a machine that must not be able to read the backups — which is the
whole reason it runs elsewhere.
All three read /etc/ra-backup/nas.conf (or $GW_BACKUP_CONF):
RESTIC_REPOSITORY=sftp:nas:/backups/goodworkshop
RESTIC_PASSWORD_FILE=/etc/ra-backup/restic.pass
GW_BACKUP_TAG=goodworkshopRetention is 14 daily, 8 weekly and 12 monthly snapshots (GW_KEEP_DAILY and friends). Write
whatever you choose here into your privacy policy as well — a retention period is a promise, and
a promise that only exists in a script is one nobody can read.
# /etc/systemd/system/goodworkshop-backup.service
[Unit]
Description=Nightly GoodWorkshop backup, offsite
OnFailure=goodworkshop-backup-failed.service
[Service]
Type=oneshot
WorkingDirectory=/opt/goodworkshop
ExecStart=/opt/goodworkshop/scripts/backup-offsite.sh
Environment=RESTIC_CACHE_DIR=/var/cache/restic
Environment=HOME=/root
Nice=10
IOSchedulingClass=idle# /etc/systemd/system/goodworkshop-backup.timer
[Unit]
Description=Nightly GoodWorkshop backup
[Timer]
OnCalendar=*-*-* 03:15:00
RandomizedDelaySec=15m
Persistent=true
[Install]
WantedBy=timers.targetEnvironment=HOME=/root is not decoration: systemd sets no HOME, restic looks for its cache
below it, and without one it re-downloads the repository index on every run. With a small
repository nobody notices; as the history grows it becomes a real brake.
The restore drill runs everything in throwaway containers — a Postgres the dump is read into, and
nothing of it touches the production stack. It creates the database roles first, without passwords:
a dump carries owner to gw_owner and the grants, but roles belong to the cluster and are in no
dump. That is exactly what db-bootstrap.mjs does in a real restore.
Possible, with three consequences: no passkeys, the session cookie carries no Secure and
travels in the clear, and sign-in links do the same.
app deliberately binds to 127.0.0.1 only — without a proxy nothing is reachable from
outside, not even with a misconfigured firewall. So access without the tls profile needs
either a reverse proxy of your own on the host or an SSH tunnel:
docker compose up -d
ssh -L 3000:127.0.0.1:3000 serverGW_APP_URL then has to point at the address the browser actually uses.
| Variable | Required | Meaning |
|---|---|---|
GW_APP_URL |
yes | Address the app is reachable at. Sign-in links and the WebAuthn origin are derived from it. |
GW_HOSTNAME |
for tls |
Name in the certificate. Passed through to Caddy. |
GW_VERSION |
yes | Image tag: latest for the newest stable release, or a release such as 0.8.40 to stay on it. Shown in the page footer and by /api/health. |
GW_MAIL_TRANSPORT |
no | smtp, graph, console or none. Empty: mail follows the settings under Mail delivery in the interface. |
SMTP_URL / SMTP_URL_FILE |
with smtp |
Relay URL, directly or from a file. |
SMTP_FROM |
with smtp |
Sender address. |
GW_GRAPH_TENANT_ID |
with graph |
Microsoft 365 tenant, as a domain or a directory id. |
GW_GRAPH_CLIENT_ID |
with graph |
Application id of the app registration. |
GW_GRAPH_CLIENT_SECRET / GW_GRAPH_CLIENT_SECRET_FILE |
with graph |
The registration's secret, directly or from a file. |
GW_GRAPH_SENDER |
with graph |
Mailbox to send from. |
GW_RP_ID |
no | WebAuthn relying party id. Empty = host from GW_APP_URL. Changing it afterwards invalidates every passkey. |
GW_TIMEZONE |
no | Time zone for dates in the interface (default Europe/Berlin). Set explicitly so server and browser format the same instant identically. |
GW_BOOTSTRAP_ADMIN_EMAIL |
no | Creates an admin on the very first start and prints their link. |
GW_BOOTSTRAP_ADMIN_FIRST_NAME |
no | First name of that admin. Optional; without it they are asked in their profile. |
GW_BOOTSTRAP_ADMIN_LAST_NAME |
no | Last name of that admin. Optional, like the first name. |
GW_OPS_TOKEN |
no | Makes /api/health verbose with the header x-ops-token (version, migration state, driver error). Without it the public endpoint is terse. |
GW_SECRET_KEY / GW_SECRET_KEY_FILE |
no | Encrypts the mail credentials entered in the interface. Leave empty: the stack generates it. Belongs in the backup — see "Backing up". |
GW_TRUSTED_PROXIES |
no | Number of proxies in front (default 1). Only for throttling and logs, never for a permission. |
GW_SESSION_IDLE_DAYS |
no | After how many unused days a session expires (default 14). |
GW_MIGRATE_ON_START |
no | 1 runs the migrations in app before it starts, for updaters like Watchtower. Needs the override under "Automatic updates". Default off. |
GW_PORT, GW_COLLAB_PORT |
no | Ports on 127.0.0.1, in case the defaults are taken. |
GW_COLLAB_URL, GW_COLLAB_INTERNAL_URL |
no | Only needed if the collaboration service is not at /collab on the same host. |
1. Passkeys need HTTPS. Except on localhost, WebAuthn passkeys do not work over
http://. Anybody hosting on http://192.168.1.50:3000 cannot use them at all. That is why
e-mail sign-in links are a full route of their own and not a stopgap — and why the tls
profile is the recommended path, not an appendix.
2. GW_RP_ID hangs off the hostname. Changing the hostname after passkeys have been
registered invalidates every registered passkey. Decide it beforehand.
3. GW_MAIL_TRANSPORT=console is a deliberate decision. Without HTTPS and without SMTP it
still gets you in: sign-in links are printed to stdout. But anybody who can read
docker logs — the docker group, a log aggregator, an excerpt sent to support — can have a
link issued for any address. The application says so again at startup.
The first administrator is claimed on /setup with a setup key. Work through these in order:
Look in the right log. The key is printed by app, not by migrate:
docker compose logs app | grep -A 4 'no administrator yet'Nothing there? Restart app. The key is only printed when the database was reachable as
app started. A restart prints the address and a fresh key again:
docker compose restart app
docker compose logs appThe key is refused. It lives in memory and changes with every restart. Use the newest key in the log, not one you copied earlier.
/setup is not there any more. An administrator already exists, so the setup route is gone
for good. That happens when GW_BOOTSTRAP_ADMIN_EMAIL was set on the first start: the one-time
link was then printed by migrate, exactly once, and was valid for an hour:
docker compose logs migrateThe link has expired, was already used, or you closed the page. Have a new one issued for the admin address. It is valid for 15 minutes and works once:
docker compose exec app node scripts/cli.mjs login-link --email you@example.comYou can sign in, but you are not an admin. Make your existing account one:
docker compose exec app node scripts/cli.mjs admin promote --email you@example.comThe link opens the wrong address. Sign-in links are built from GW_APP_URL. If it does not
match the address in your browser, fix it in the .env and run
docker compose --profile tls up -d again.
Mail is not configured until somebody configures it. Check Mail delivery in the interface
and GW_MAIL_TRANSPORT in the .env — the .env wins. With console the link is in
docker compose logs app. Without any mail, cli.mjs login-link above always gets you in.
| Symptom | Cause |
|---|---|
GW_APP_URL must be set on up |
A required value is missing from the .env. The message names it. |
| Caddy does not start, "GW_HOSTNAME must be set" | tls profile without a hostname. |
| The certificate is not issued | The hostname does not resolve to this server, or 80/443 are taken. |
/api/health answers 503 with database |
Database unreachable or still starting. |
/api/health answers 503 with migrations |
migrate did not come through: docker compose logs migrate. |
migrate stops with "MIGRATION STOPPED" |
The preflight found rows standing in the way of a migration. The database is unchanged; the message names the rows and the decision. |
| No setup key in the log | See I cannot find the link for the admin account. |
| No sign-in link in the inbox | Mail is not configured yet, see above. With GW_MAIL_TRANSPORT=console it is in docker compose logs app. |
Graph answers 403 or invalid_client |
The app registration does not have the application permission Mail.Send with admin consent, or the secret has expired. The message is in app's log. |
| Sign-in works, no passkey offered | No HTTPS — expected behaviour, see above. |
| The editor permanently shows "offline" | The collaboration service is unreachable, or GW_APP_URL does not match the address in the browser: the socket refuses a foreign origin and writes that to the log. |
| The interface is in the wrong language | The language comes from your account (Settings), then a cookie, then the browser's Accept-Language. A fresh account starts in the language of whoever invited it. |
Apache License 2.0 with the Commons Clause. The terms are in LICENSE, the copyright notice in NOTICE. What follows is a summary in plain words; where it and the licence text differ, the licence text is what counts.
- Use it for free, commercially too. A company may run GoodWorkshop for its own people and in its own business — host it, change it, build on it, and plan and run the workshops it earns its money with.
- Do not sell GoodWorkshop itself. Nobody may offer it to third parties for a fee — hosted or otherwise — as a product or service whose value comes entirely or substantially from GoodWorkshop's functionality.
- That includes paid hosting and paid support for the software. The Commons Clause names "fees for hosting or consulting/support services related to the Software" in so many words. Charging somebody to install, run or support GoodWorkshop for them counts.
- Which is why goodworkshop.org is ours to run and not yours. A licence binds whoever receives the software, not the copyright holder: roleALPHA GmbH may offer a hosted GoodWorkshop because the terms above are the ones it grants. If you want to offer it to your own customers, that needs a separate agreement — ask.
This is source-available, not open source in the OSI sense: the Commons Clause restricts a use that an open-source licence has to allow. GitHub accordingly shows the licence as "Other".
Earlier versions. Releases up to and including v0.3.1 were published under AGPL-3.0-only
and have since been withdrawn — their release pages, tags and container images are gone. Anyone
who received one keeps the rights that licence grants for that copy; the new terms apply from
v0.4.0.
Other people's code. The image ships with dependencies under their own licences.
THIRD-PARTY-LICENSES.md is the index, and the notices themselves —
copyright lines and full licence texts — are inside every image at
/app/THIRD-PARTY-LICENSES.txt and attached to every release. CI refuses a dependency whose
licence cannot ship under these terms — GPL and AGPL among them, because their copyleft forbids
exactly the restriction the Commons Clause adds.
Bill of materials. Every release carries sbom.cdx.json, a CycloneDX document listing the
exact versions that went into it. The published image additionally carries an SPDX attestation
generated by BuildKit, which you can read without pulling the image:
docker buildx imagetools inspect ghcr.io/rolealpha/good-workshop:0.8.40 --format '{{ json .SBOM }}'Running it for other people. If you host this for anyone but yourself, you are the controller for their data. docs/data-protection.md says what is stored, what leaves the server, and — plainly — what the software does not clean up for you.
pnpm install
pnpm db:up # Postgres and a mail catcher for development
pnpm dev| Command | Purpose |
|---|---|
pnpm lint |
ESLint including the project guardrails |
pnpm typecheck |
tsc --noEmit |
pnpm test |
Vitest (unit + component) |
pnpm test:coverage |
with coverage thresholds on src/domain, src/features, src/i18n |
pnpm test:db |
tenant isolation and auth against a real database |
pnpm test:e2e |
Playwright (desktop + Pixel 5) against the built standalone server |
pnpm format |
Prettier |
pnpm build |
production build (output: 'standalone') |
pnpm check:docs |
checks that README, .env.example and compose.yaml still fit the code |
pnpm check:licenses |
checks every dependency may ship under this project's licence, and that the index is current |
pnpm licenses:write |
regenerates THIRD-PARTY-LICENSES.md after a dependency change |
The conventions below are also published as agent skills in
.agents/skills/, so a coding assistant picks up the same rules a person
does. The skills carry no content of their own — each one points at the document, because two
copies of a rule are one copy that drifts.
Three binding conventions, for every contribution:
- UI and UX — mobile-first, editing in place rather than in dialogs, colour tokens, branding, accessibility
- Tests — the test pyramid, tenant-isolation tests, flake policy
- CI/CD — GitHub Actions, Docker build, release to
ghcr.io
Before your first pull request, read CONTRIBUTING.md. It is short and it is
the legal half: every commit needs a Signed-off-by line (git commit -s), which CI checks.
Three guardrails are enforced by ESLint and are not a matter of style:
- No raw hex colours in
src/components,src/features,src/app. Category colours go through the.cat-*OKLCH tokens, accents through--brand-*. - No German prose in those same directories. Every string a person reads lives in
src/messages; see Languages. - No import of the raw
dbhandle outsidesrc/server/db. Every query runs throughwithTenant()so thatapp.tenant_idis set — tenant isolation depends on it.
Found something that looks like a security problem? SECURITY.md says where it goes — privately, not into an issue — and which boundaries are worth attacking.
If you work on the installation, the migrations or the documentation, the details are here:
- Keeping the docs honest — what
pnpm check:docschecks, what it cannot, and what to update after which change - Installation and upgrade — the startup chain, the preflight, the database roles and what breaks easily when changing them
- Languages — how the catalogs are cut, where the language comes from, and what deliberately stays untranslated




