Dexio / how-we-build-dexio / app
Open-sourcing the server behind a hosted product
The Dexio server is public at https://github.com/dexio-wiki/dexio. The hosted service at app.dexio.wiki runs the same code: app-server-and-releases. Its MCP endpoint: remote-mcp-server-with-oauth.
What it is
- The whole server, licensed AGPL-3.0, published from one fresh commit. The old repository, with its full history, stays private under another name.
- Self-hosting with Docker Compose, documented in
deploy/README.mdwith a.env.example. - A public image,
ghcr.io/dexio-wiki/dexio, for amd64 and arm64, built on every push to main after a smoke test, taggedlatest, the commit SHA, and the version on release tags. - A self-hosted server runs with nothing configured: no billing, no mail provider, no cloud account. Features that need them stay off.
- The hosted service builds its own image in its own pipeline; it does not pull the public one.
Why it is built this way
- AGPL rather than MIT. AGPL's network clause means anyone who runs a modified copy as a service must offer its source to their users. Self-hosting stays free and complete; a closed hosted fork does not.
- A fresh history rather than a cleaned one. Our old history held test fixtures with private material and personal email addresses as commit authors. Rewriting history is easy to get wrong and hard to check; one new commit has nothing to audit but the tree.
- A separate public image. The hosted service pulls a tested image by digest from a private registry. Self-hosters need a public image with stable tags; keeping the two apart means a public tag can never change what the hosted service runs.
How to build it
-
Audit the tree, not the history: scan for credentials, private names and addresses in fixtures and docs, and anything that points at internal systems. Fix it in the tree.
-
Rename the old repository (ours is
-archive) and keep it private. Create the public repository with one commit of the cleaned tree, authored with your GitHub no-reply address (<id>+<user>@users.noreply.github.com). If your shell exportsGIT_AUTHOR_EMAIL, that overrides the repo's config; set it per commit. -
Add
LICENSE(AGPL-3.0),SECURITY.mdwith an address for reports, anddeploy/with the Compose file, a reverse proxy config and.env.example. -
Make every hosted-only feature optional and off by default. Our examples: with no Stripe key the server sells no plans and enforces no member or storage limits; analytics loads only on our own domain, so a self-hosted copy sends nothing to Google; the public URL used in OAuth and emails comes from one variable.
-
Add the image workflow (
.github/workflows/image.yml): build the runtime stage for amd64, start it with nothing configured, check/healthz, a sign-up and the OAuth issuer, then build and push both architectures:- name: Smoke test run: | docker run -d --name dexio -p 8080:8080 -e DEXIO_PUBLIC_URL=http://127.0.0.1:8080 dexio:smoke for i in $(seq 1 30); do curl -fsS http://127.0.0.1:8080/healthz && break; sleep 1; done issuer=$(curl -fsS http://127.0.0.1:8080/.well-known/oauth-authorization-server | jq -r .issuer) test "$issuer" = http://127.0.0.1:8080 - uses: docker/metadata-action@v5 with: images: ghcr.io/dexio-wiki/dexio tags: | type=raw,value=latest,enable={{is_default_branch}} type=sha,format=long,prefix= type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} -
Make the package public. GitHub creates it private. In an organization, the owner first allows public packages (Settings, Packages, Package creation), then changes the package's visibility in the web UI; there is no API for it. A public package cannot be made private again, and public packages are free.
-
Point the hosted release pipeline at the new repository and release once. Check health, a sign-in, and that the OAuth issuer is unchanged, since every connected client compares it.
Verify
- Signed out of GitHub,
docker pull ghcr.io/dexio-wiki/dexio:latestworks on both an x86 and an ARM machine. - Follow
deploy/README.mdon a clean machine and sign up. - The hosted service's first release from the new repository passes its checks.
Pitfalls we hit
- Tests passed for the wrong reason: the developer's shell exported billing keys, so tests of the no-billing limits never ran without them. Strip the environment in the test setup.
- The organization setting that allows public packages was off, and the error said only that the setting was disabled by administrators.
- A version string in the server's handshake and API docs disclosed more than we wanted; the API docs also listed every internal route. We pinned the version and turned the docs off.