Skip to content

Self-hosting

Serve the playground and slides --server from your own machine with Caddy: automatic HTTPS, the headers the playground needs, and an OpenID Connect sign-in in front.

The playground is static files, and slides --server is one process on the loopback. Both can run on your own machine, behind your own name, for people who sign in with your organisation’s account. This page does it with Caddy (tested with v2.11.7), which obtains and renews the HTTPS certificates itself, and oauth2-proxy for the sign-in. Every file shown here is in selfhost/, and its CI checks each Caddyfile with caddy validate.

The playground

Each release of the site attaches the whole site, playground included, as one tarball built for the root of a domain, with its SHA-256:

curl -fLO https://github.com/go-slides/go-slides.github.io/releases/download/v0.1.0/go-slides-site-v0.1.0.tar.gz
curl -fLO https://github.com/go-slides/go-slides.github.io/releases/download/v0.1.0/go-slides-site-v0.1.0.tar.gz.sha256
sha256sum -c go-slides-site-v0.1.0.tar.gz.sha256
tar -xzf go-slides-site-v0.1.0.tar.gz -C /srv
mv /srv/go-slides-site-v0.1.0 /srv/go-slides-site

The tarball is reproducible: building the same tag again gives the same bytes. Beside every file that compresses (the WebAssembly modules, the fonts, the pages, the scripts) it carries the same file compressed with Brotli (.br) and gzip (.gz), so the server sends them as they are and compresses nothing on the fly.

Caddyfile

slides.example.org is your name. Its A and AAAA records point at the machine, and ports 80 and 443 reach Caddy:

import headers.caddy

slides.example.org {
	root * /srv/go-slides-site
	file_server {
		precompressed br gzip
	}
	import playground_headers
}
caddy run --config Caddyfile.playground

The playground is then at https://slides.example.org/playground/.

What the server must get right

  • HTTPS. Caddy gets the certificate from Let’s Encrypt with ACME (RFC 8555), proving that it holds the name by answering on port 80 (HTTP-01) or 443 (TLS-ALPN-01), and renews it. A machine that the CA cannot reach can use the DNS-01 challenge instead (a TXT record, which needs a Caddy built with your DNS provider’s module), or your own certificate with tls cert.pem key.pem. On a test machine, tls internal uses Caddy’s local CA.
  • application/wasm. Caddy sends this type for .wasm; another server may need to be told. The playground compiles its modules from the bytes it has read (the export engine as it downloads, in a response of its own with that type), so it starts even from a server that sends another type; with nosniff, the right type is still the one to send.
  • Compression. precompressed br gzip sends playground.wasm.br to a browser that accepts Brotli, .gz to one that accepts gzip, and the file itself otherwise, with Vary: Accept-Encoding. The editor’s module is about 7 MiB in Brotli, against 33 MiB uncompressed.
  • Caching. The page asks for every module, worker, font and picture with the release in the query (playground.wasm?v=v0.1.0), so those can be kept for a year (immutable). The pages themselves are revalidated (no-cache): a new release is seen at the next visit.
  • The headers in headers.caddy:
(playground_headers) {
	header {
		Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self' https:; img-src 'self' data: blob: https:; media-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' data: blob: https:; frame-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'"
		X-Content-Type-Options nosniff
		Referrer-Policy no-referrer
		Strict-Transport-Security "max-age=31536000"
	}
	@versioned query v=*
	header @versioned Cache-Control "public, max-age=31536000, immutable"
	@unversioned not query v=*
	header @unversioned Cache-Control "no-cache"
}

The policy is the one the playground needs: its scripts (the loader is inline), its WebAssembly ('wasm-unsafe-eval') and its worker, all from your server. A deck’s pictures, style sheets and fonts may come from any https: address, as they do in the command’s output; drop https: from img-src, style-src, font-src, media-src and connect-src to keep every request on your server, and decks that point elsewhere show without those resources. The deck itself runs in iframes sandboxed without your origin, under a stricter policy of their own that the playground writes into each frame: it cannot reach the playground, its storage or your cookies, whatever this header says. frame-ancestors 'self' stops other sites from framing the playground.

  • No COOP or COEP. The playground uses neither SharedArrayBuffer nor threads, so it does not need to be cross-origin isolated. Sending Cross-Origin-Embedder-Policy: require-corp would block a deck’s pictures from other sites.

Without the network

Once the page is loaded, everything happens in the browser: the editor, the preview, the presenter view, and the PDF, PPTX and ODP exports, whose fonts are in the tarball. The deck is sent neither to your server nor anywhere else. What needs the network is what the deck names: pictures or style sheets at an https: address, and the gaia theme’s web font, which it asks of fonts.bunny.net (without it, the slides fall back to another font). The links to this documentation leave your server.

slides --server behind the proxy

slides --server stays on 127.0.0.1 and Caddy forwards to it. The server checks the Host header of each request and the Origin of each live reload’s WebSocket, against DNS rebinding and other sites’ pages, and the proxy keeps the public name in both. --allowed-host adds that name to what the checks accept; they stay on:

slides -s --allowed-host decks.example.org /srv/decks
decks.example.org {
	reverse_proxy 127.0.0.1:8080
}

The live reload goes through as it is (Caddy proxies WebSockets without configuration). Do not use --host 0.0.0.0 for this: it serves the directory to the whole network, around the proxy and its sign-in.

--allowed-host is in v0.14.0 and later: go install github.com/go-slides/slides/cmd/slides@v0.18.0. It needs Go 1.27.3 or later.

Sign-in with OpenID Connect

Caddy asks oauth2-proxy about every request (forward_auth). oauth2-proxy answers 202 for a browser that has signed in, which Caddy then serves, and 401 otherwise, which Caddy turns into a redirect to the sign-in. The sign-in is the authorization code flow of OpenID Connect Core 1.0: the browser goes to your provider (Keycloak, Authentik, Entra ID, Google, Okta, Dex…), comes back to /oauth2/callback with a code, oauth2-proxy exchanges the code for an ID Token, checks it, and sets its own cookie.

  1. Register a client with your provider, confidential, with the authorization code flow. Its redirect URIs, which must match exactly: https://slides.example.org/oauth2/callback and https://decks.example.org/oauth2/callback.
  2. Put the secrets in files readable by oauth2-proxy alone: the client’s secret, with no newline at the end, and a cookie secret of exactly 32 random bytes (head -c 32 /dev/urandom). Your secret store writes them (systemd credentials, Docker or Kubernetes secrets, Vault…); neither goes into a configuration file, a command line or an environment variable, where logs and ps show them.
  3. Configure oauth2-proxy (oauth2-proxy.cfg):
http_address = "127.0.0.1:4180"
reverse_proxy = true
trusted_proxy_ips = ["127.0.0.1/32", "::1/128"]
upstreams = ["static://202"]
set_xauthrequest = true

provider = "oidc"
oidc_issuer_url = "https://id.example.org/realms/example"
client_id = "go-slides"
client_secret_file = "/run/secrets/oauth2-proxy-client-secret"
code_challenge_method = "S256"

redirect_url = "/oauth2/callback"
whitelist_domains = ["slides.example.org", "decks.example.org"]
email_domains = ["example.org"]

cookie_secret_file = "/run/secrets/oauth2-proxy-cookie-secret"
cookie_secure = true
cookie_samesite = "lax"

redirect_url without a host brings each site’s sign-in back to its own callback, so each site keeps its own cookie. whitelist_domains is where a sign-in may return to: asked to go anywhere else, it stays on your site. trusted_proxy_ips believes the X-Forwarded-* headers from Caddy alone. email_domains says who may come in; oauth2-proxy also has options for groups and lists of addresses.

  1. Configure Caddy (Caddyfile.oidc):
import headers.caddy

(signed_in) {
	handle /oauth2/* {
		reverse_proxy 127.0.0.1:4180
	}
	handle {
		forward_auth 127.0.0.1:4180 {
			uri /oauth2/auth
			copy_headers X-Auth-Request-User X-Auth-Request-Email
			@unauthorized status 401
			handle_response @unauthorized {
				redir * /oauth2/sign_in?rd={scheme}://{hostport}{uri} 302
			}
		}
		{block}
	}
}

slides.example.org {
	import signed_in {
		root * /srv/go-slides-site
		file_server {
			precompressed br gzip
		}
		import playground_headers
	}
}

decks.example.org {
	import signed_in {
		reverse_proxy 127.0.0.1:8080
	}
}
slides -s --allowed-host decks.example.org /srv/decks
oauth2-proxy --config /etc/oauth2-proxy/oauth2-proxy.cfg
caddy run --config Caddyfile.oidc

oauth2-proxy and slides listen on the loopback only: the one way in is through Caddy, and so through the sign-in. The playground’s headers go inside the signed-in block, on the playground alone: oauth2-proxy’s sign-in page is a form, which their form-action 'none' would stop.

The alternative is caddy-security, a Caddy module that does the sign-in inside Caddy, with no second process. It needs a Caddy built with the module (xcaddy), and its own configuration language.

slides-gitd: the credentials stay on the server

slides-gitd (v0.1.0) is the git backend for a self-hosted playground. It loads a deck’s folder from a forge, proposes changes as a new branch and a pull (merge) request, and checks for upstream changes, while the forge credentials stay on the server: the browser holds only an HttpOnly session cookie. It sits behind Caddy and oauth2-proxy (OIDC), listens on 127.0.0.1:8091, takes the user’s identity from the proxy only, and writes only to branches named slides/<user>/<title>-<time> that the server names itself.

The playground’s server mode, which talks to slides-gitd instead of the browser holding a token, is described in The playground behind slides-gitd.

Configuration (secrets are file paths, never values; each file must be readable by its owner only):

{
  "listen": "127.0.0.1:8091",
  "origin": "https://slides.example.org",
  "proxy_secret_file": "/etc/slides-gitd/proxy.secret",
  "master_key_file": "/etc/slides-gitd/master.key",
  "data_dir": "/var/lib/slides-gitd",
  "audit_file": "/var/log/slides-gitd/audit.log",
  "forges": {
    "github": {"kind": "github", "api": "https://api.github.com", "auth": "github-app",
               "app_client_id": "Iv1.…", "installation_id": 12345678,
               "app_key_file": "/etc/slides-gitd/github-app.pem"},
    "gitlab": {"kind": "gitlab", "api": "https://gitlab.example.org/api/v4", "auth": "user-token"}
  },
  "users": {
    "3f2b…-oidc-sub": {"slug": "ada", "repos": ["github:lab/talks", "gitlab:team/slides"]}
  }
}

A user reaches only the forge:owner/name entries listed for their OIDC subject (deny by default). Create the master key with head -c 32 /dev/urandom > master.key && chmod 600 master.key, then run slides-gitd check -c config.json, which loads every secret file and exits, and slides-gitd serve -c config.json.

The Caddy side, with oauth2-proxy started with --set-xauthrequest:

slides.example.org {
    handle /oauth2/* {
        reverse_proxy 127.0.0.1:4180
    }
    handle /api/git/* {
        # never let a client choose its identity, whatever Caddy's version does
        request_header -X-Auth-Request-User
        request_header -X-Auth-Request-Email
        request_header -X-Gitd-Proxy-Secret
        forward_auth 127.0.0.1:4180 {
            uri /oauth2/auth
            copy_headers X-Auth-Request-User X-Auth-Request-Email
        }
        request_body { max_size 64MB }
        reverse_proxy 127.0.0.1:8091 {
            header_up X-Gitd-Proxy-Secret {file./etc/caddy/gitd-proxy.secret}
        }
    }
    handle { root * /srv/slides; file_server }
}

The request_header -… lines delete any identity header the client sent, so a client cannot choose who it is; recent Caddy does this itself before copy_headers, older versions did not. {file.*} reads the proxy secret from a file, so it is not in an environment variable.

GitHub uses an App. Create it under Settings ▸ Developer settings ▸ GitHub Apps with the permissions Contents read & write, Pull requests read & write and Metadata read, and no webhook; install it on the deck repositories only. slides-gitd mints an installation token for the one repository of each call, which lives an hour.

GitLab: each user stores a personal or project access token with the scope api. Why api and not write_repository, and what a stolen token can do, is explained on the playground page; prefer a project or group access token limited to the deck repositories, or a dedicated account.

The playground behind slides-gitd (server mode)

When the playground is served behind slides-gitd on its own origin, it asks GET /api/git/session on that origin to find out. 401 means a server is there and nobody is signed in: the pane shows Sign in. A JSON session means signed in. Anything else, such as the 404 of a static host, means there is no server, and the pane works exactly as on go-slides.github.io: a token kept in the tab, or the GitLab sign-in.

In server mode the pane hides the token and OAuth fields. It shows Signed in as <identity> and the list of repositories the server grants. Load, Propose changes… (the server names the branch slides/<readable>-<hash>/…; the page cannot choose one), Check for changes and Take theirs all go to /api/git/* on the same origin. The browser contacts no forge host.

What stays where:

  • Tokens live on the server, in an AES-GCM vault with one key per user.
  • The browser holds only the __Host-gitd cookie (HttpOnly, Secure, SameSite=Strict), which scripts cannot read.
  • The CSRF token is kept in the page’s memory only and is never stored.
  • Every write carries the exact Origin, X-Requested-With: slides-gitd, a JSON body and X-CSRF-Token.

When the sign-in has expired or is missing (HTTP 401), the pane shows “Your sign-in has ended” with a Sign in button (oauth2-proxy’s /oauth2/sign_in?rd=<this page>). It never falls back to a token typed in the browser.

To serve it, use selfhost/Caddyfile.gitd of go-slides/go-slides.github.io. Its Content-Security-Policy is connect-src 'self'; the site stays public and only /api/git/* sits behind oauth2-proxy. slides-gitd’s origin setting must equal the playground’s origin. A forge whose auth is user-token (GitLab, for example) needs each user’s own token stored on the server through POST /api/git/credential. The pane offers no field for it, so the administrator or the user does it outside the page.

One limit: the playground keeps drafts and the last repository name in the browser profile (IndexedDB and localStorage), as before. On a shared browser profile, use Draft ▸ Forget my drafts or separate profiles. In server mode, a remembered repository the user is not granted is dropped.

Check it

curl -sI -H 'Accept-Encoding: br' 'https://slides.example.org/playground/playground.wasm?v=v0.1.0'

The answer says content-type: application/wasm, content-encoding: br and cache-control: public, max-age=31536000, immutable, with the content-security-policy above. With the sign-in in front, the same request without a cookie answers 302 to /oauth2/sign_in.

References

Created on by tannevaled

Modified on by tannevaled