Self-hosting

You can serve Emet’s dist/ directory from Netlify, Vercel, GitHub Pages, nginx or anywhere else that serves files. The static part is genuinely that simple. What the repository doesn’t give you on those platforms is the OAuth token exchange, and without it nobody can sign in. So this page is mostly about that one endpoint, and only briefly about the hosting.

If you haven’t read Deployment for why the endpoint exists, do that first.

What you’ll need#

  • Node.js 22.12 or later, and a Git checkout of Emet (not a tarball; the build runs git rev-parse to stamp the commit hash into the version string at the bottom of the sidebar).
  • A GitHub App with its client ID and secret.
  • Somewhere to run a small HTTPS endpoint: a serverless function on your host, a Cloudflare Worker, a few lines of Express behind nginx, whatever you have.

Writing the token proxy#

The reference implementation is functions/api/github/token.ts, which is about fifty lines, and your version needs to do the same things:

  1. Accept POST requests with a JSON body. Emet sends client_id and grant_type, plus either code, redirect_uri and code_verifier (for the initial sign-in) or refresh_token (when the token expires). It never sends a secret.
  2. Add client_secret from the environment to that body.
  3. Forward the body as JSON to https://github.com/login/oauth/access_token with an Accept: application/json header.
  4. Return GitHub’s JSON response with GitHub’s status code. Emet reads access_token, refresh_token, expires_in and friends straight from it, and shows GitHub’s error_description if there is one.
  5. Answer OPTIONS preflight requests, and send Access-Control-Allow-Origin for your CMS’s origin on both the preflight and the POST. The browser is calling your proxy cross-origin, so without these headers the request never leaves the browser.

Name the CMS’s exact origin rather than *. The shipped functions do this in functions/_middleware.ts: same-origin requests pass through untouched, an origin listed in the ALLOWED_ORIGINS environment variable gets Access-Control-Allow-Origin set to that origin (plus Vary: Origin), and any other cross-origin request is refused with a 403 before it reaches the token endpoints. If you copy the functions to another host, copy the middleware too, or do the same check in your own code.

If you also want Google Search Console, functions/api/gsc/ has three more functions (token, refresh and revoke) to port in the same way. They need GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET in their environment.

Warning
Your proxy must be served over HTTPS. Production builds carry a Content Security Policy with connect-src 'self' https:, so a proxy on plain http:// is blocked by the browser before the request is sent, even on localhost.

Building with the proxy’s address#

VITE_API_BASE_URL is the origin (or origin plus path prefix) that Emet puts in front of /api/github/token and /api/gsc/*:

VITE_GITHUB_CLIENT_ID=Iv23.your_client_id \
VITE_API_BASE_URL=https://auth.example.com \
npm run build

With that build, sign-in posts to https://auth.example.com/api/github/token. A trailing slash on the base URL is stripped, so https://auth.example.com/ works too. Leave DEPLOY_TARGET unset; _routes.json only means something to Cloudflare.

The output is dist/. Its asset paths are relative, so it works at the root of a domain or in a subdirectory.

Hosting the files#

Emet navigates with the URL hash and handles the OAuth callback with a query string on the page it was loaded from, so you don’t need the usual single-page-app rewrite rule that sends every path to index.html. Serve the directory as it is.

For GitHub Pages, a workflow like this builds on push and publishes dist/:

name: Deploy CMS

on:
  push:
    branches: [master]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22.12'
          cache: 'npm'
      - run: npm ci
      - run: npm run build
        env:
          VITE_GITHUB_CLIENT_ID: ${{ vars.GITHUB_CLIENT_ID }}
          VITE_API_BASE_URL: ${{ vars.API_BASE_URL }}
      - uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./dist

Netlify and Vercel are the same idea: build command npm run build, publish directory dist and the two VITE_ variables in the project’s build environment. Both let you run the proxy as a function on the same platform; put it on a separate origin from the CMS or on the same one, whichever is easier, as long as VITE_API_BASE_URL points at it.

For nginx or any plain web server, copy dist/ to the document root and serve it over HTTPS. Nothing in the directory needs special handling, though long cache headers on assets/ are safe since the filenames are hashed.

Putting Emet inside your Hugo site#

A variation on the above is to copy the build into your Hugo site’s static/ folder, so the CMS is published with the site at https://example.com/emetcms/:

cp -r dist/ /path/to/hugo-site/static/emetcms/

This is tidy, since there’s no second deployment to manage, but it has exactly the same constraint: your Hugo site’s host isn’t running the token exchange, so you still need the proxy somewhere and VITE_API_BASE_URL in the build. The GitHub App callback URL in this case is https://example.com/emetcms/, trailing slash included.

Where next?#

  • A blank page or a failed sign-in usually comes down to the callback URL or a missing VITE_GITHUB_CLIENT_ID; GitHub App covers both.
  • Cloudflare Pages if you’d rather skip writing the proxy.