dokku-google-auth
Put Google OAuth SSO in front of any dokku app with one command. Configure Google once, then:
dokku google-auth:enable my-app
Every request to my-app now requires a signed-in Google account from your
organization. The app itself never touches OAuth — it just reads the
signed-in user from request headers. Paths that should stay open (webhooks,
API-key-protected endpoints, health checks) can be excluded per app.
How it works
┌──────────────────────── dokku host ────────────────────────┐
│ │
browser ── https ──▶ │ nginx (per-app vhost, unchanged dokku routing) │
│ │ │
│ ├─ auth_request GET /_google-auth/verify ──┐ │
│ │ 201? no: 302 to Google sign-in │ │
│ │ ▼ │
│ │ ┌────────────────────────┐ │
│ │ │ google-auth-proxy │ │
│ │ │ (one shared container, │ │
│ │ │ 127.0.0.1:2999) │ │
│ │ └────────────────────────┘ │
│ ▼ │
│ app container ◀── X-Forwarded-Email / X-Forwarded-User │
└────────────────────────────────────────────────────────────┘
- One shared Go service (
google-auth-proxy) runs in a single Docker container on the host, bound to127.0.0.1. It holds the Google client credentials and handles the entire OAuth dance. - Per-app nginx config is injected through dokku's standard
~dokku/<app>/nginx.conf.d/include. It uses nginx'sauth_requestmodule: every request triggers a fast subrequest to the auth service, which checks an encrypted session cookie. No valid session → the browser is redirected through Google sign-in and back. - Only one redirect URI is ever registered with Google (the "auth
host"). When a user signs in to
app-a.example.com, Google redirects tohttps://<auth-host>/_google-auth/callback; the service verifies the identity, then bounces the browser toapp-awith a single-use, 60-second, encrypted hand-off token, which becomes a session cookie scoped toapp-a's own domain. Adding app #47 therefore requires zero changes in the Google Console — apps don't even need to share a parent domain. - Sessions, OAuth state, and hand-off tokens are all AES-256-GCM encrypted
and authenticated with a secret generated at configure time. Session
cookies are
Secure,HttpOnly,SameSite=Lax, and pinned to the host they were minted on. - Per-app access lists work because every generated location tells the
service which app the request belongs to, via an
X-Google-Auth-Appheader that nginx sets itself — overwriting anything a client sent. The plugin'sapps/directory is bind-mounted into the container read-only, so the service reads an app's lists on demand instead of needing a restart. The destination app also rides through the OAuthstateparameter, because Google's callback lands on the auth host, which may belong to a different app than the one being signed in to.
Headers your apps receive
On every authenticated request, nginx injects (and strips anything the client tried to spoof):
| Header | Value |
|---|---|
X-Forwarded-Email / X-Auth-Request-Email |
signed-in Google email (lowercased) |
X-Forwarded-User / X-Auth-Request-User |
stable Google account id (sub claim) |
X-Auth-Request-Name |
display name |
On excluded paths these headers are set to the empty string, so your app
can trust that a non-empty X-Forwarded-Email always came from the plugin.
How dokku plugins work (background)
A dokku plugin is just a git repo of executable shell scripts. Dokku clones
it into /var/lib/dokku/plugins/available/<name> and:
plugin.toml— metadata (description, version).install— runs as root atplugin:install/plugin:updatetime. This plugin uses it to create its data directory anddocker buildthe Go auth service image (multi-stage build, so the host only needs Docker, not Go).commands+subcommands/<name>— dokku dispatchesdokku google-auth:enable footosubcommands/enable, passing the full command line.commandsprovidesdokku google-auth:help. This is why the plugin must be installed under the namegoogle-auth.- Triggers — executables named after lifecycle hooks that dokku (via
plugn) calls with arguments. This plugin
implements:
nginx-pre-reload/core-post-deploy— regenerate the app'snginx.conf.d/google-auth.confon every deploy, so it always references the app's current upstream (whose name changes if you remap ports).post-delete,post-app-rename,post-app-clone— keep plugin state in sync with app lifecycle.
- State lives under
/var/lib/dokku/data/google-auth/:global/for the shared config andapps/<app>/for each app's exclusions and access lists. The data root is0700and secrets are0600.apps/is0711with0644list files, because it is bind-mounted into the service container, which runs as an unprivileged uid and has to read those lists — the0700root still keeps other host users out, andglobal/is never mounted.
The nginx integration relies on a stable, documented dokku feature: the
generated vhost for every app contains
include /home/dokku/<app>/nginx.conf.d/*.conf; inside the server block,
which is exactly where this plugin drops its location blocks.
Installation
On the dokku host:
sudo dokku plugin:install https://github.com/<you>/dokku-google-auth.git --name google-auth
(The --name google-auth matters — subcommand dispatch is keyed off the
plugin directory name.)
Requirements: dokku with the default nginx proxy (not traefik/caddy),
Docker (always present on a dokku host), and nginx built with
http_auth_request_module (true for stock Debian/Ubuntu nginx).
1. Create the Google OAuth client (one time, ever)
-
Configure the OAuth consent screen if you haven't: Internal user type (recommended for a Workspace org — it automatically limits sign-in to your organization and skips app verification).
-
Create Credentials → OAuth client ID → Web application.
-
Add exactly one Authorized redirect URI:
https://<auth-host>/_google-auth/callback<auth-host>is any HTTPS domain that routes to an app you will enable the plugin on. Pick your most permanent app's domain, or add a dedicated domain (e.g.auth.example.com) to one of your apps withdokku domains:add <app> auth.example.com(plus DNS + letsencrypt as usual). All other apps piggyback on it. -
Note the client ID and client secret.
2. Configure the plugin
configure is re-runnable: every flag it takes is persisted and can be
changed later by passing it again.
dokku google-auth:configure \
--client-id 1234567890-abc.apps.googleusercontent.com \
--client-secret GOCSPX-xxxxxxxxxxxx \
--auth-host auth.example.com \
--allow-domain signal.org
This starts the shared auth service container and prints the callback URL to double-check against the Google Console.
Other flags (all optional, all persisted):
| Flag | Meaning | Default |
|---|---|---|
--allow-domain <d> |
allow any verified *@d account (repeatable; replaces the stored list) |
— |
--allow-email <e> |
allow a specific address, e.g. an outside collaborator (repeatable) | — |
--deny-email <e> |
block a specific address even if the allow lists cover it (repeatable; replaces the stored list) | — |
--clear-deny-emails |
empty the deny list | — |
--session-ttl <dur> |
how long a sign-in lasts (24h, 72h, 30m, …) |
24h |
--cookie-name <n> |
session cookie name | _google_auth |
--port <p> |
host port (127.0.0.1 only) for the auth service | 2999 |
--regenerate-cookie-secret |
rotate the session encryption key (signs everyone out) | — |
These flags set the global lists and replace the list they name, which suits initial setup. For one-at-a-time changes afterwards, see the next section.
3. Decide who is allowed in
Access lists exist in two scopes — one global default and an optional override
per app. Every command takes the scope as its first argument: an app name, or
--global.
dokku google-auth:allow --global signal.org # everyone at signal.org, by default
dokku google-auth:allow my-app ceo@signal.org # …but my-app is just this person
dokku google-auth:deny --global former@signal.org # nobody, anywhere
dokku google-auth:deny my-app bob@signal.org # bob, only on my-app
The rules, in the order the auth service applies them:
- A deny match rejects the account. The global and per-app deny lists are combined, so a global denial cannot be lifted by an app.
- Otherwise the allow list decides, and it is strict: an account matching nothing is rejected. There is no "allow everyone" mode, and at least one global allow entry is required.
- An app with its own allow entries uses only those, ignoring the global list entirely. An app with none inherits the global list.
That third rule is the useful one and the surprising one. It lets a single app be narrowed to a few people, or opened to an outside collaborator who is not in the global list at all:
global: signal.org |
effect | |
|---|---|---|
app-a |
no entries | anyone @signal.org |
app-b |
ceo@signal.org |
only ceo@signal.org |
app-c |
guest@partner.com |
only guest@partner.com — not @signal.org |
It also means the global list is a default, not a ceiling: an app can admit
someone it does not cover. google-auth:allow warns the first time an app
gains an entry, since that is the moment it stops inheriting.
All eight forms:
dokku google-auth:allow --global # show the global allow list
dokku google-auth:allow my-app # show my-app's (or that it inherits)
dokku google-auth:allow --global signal.org # a domain — any verified account there
dokku google-auth:allow my-app guest@partner.com # one address
dokku google-auth:unallow my-app guest@partner.com # remove either kind
dokku google-auth:deny --global # show the global deny list
dokku google-auth:deny my-app # show my-app's, plus the global ones
dokku google-auth:undeny --global former@signal.org
Changes take effect on the affected user's next request: session cookies
are re-checked against the current lists rather than trusted until they
expire, so denying (or unallowing) someone with a live session ends it — and a
session minted for one app is not accepted by an app whose list excludes them.
To sign out everyone at once instead, use
configure --regenerate-cookie-secret.
Two guardrails: unallow --global refuses to remove the last global entry,
since an empty global allow list locks everyone out of every app that inherits
it (emptying an app's list is fine — it goes back to inheriting).
dokku google-auth:report shows the global lists and each app's, and states
whether an app inherits or overrides.
Global lists reach the auth service in its environment, so changing one restarts the shared container. Per-app lists are read from disk on demand, so changing one takes effect within a couple of seconds with no restart and no interruption to other apps.
4. Protect apps
dokku google-auth:enable my-app
dokku google-auth:enable other-app
...
That's it. Visit the app in a browser — you'll be bounced through Google and back.
5. Exclude paths (optional, per app)
# Path prefix — everything under it is open:
dokku google-auth:exclude my-app /api/webhooks
# Regex (prefix with re:):
dokku google-auth:exclude my-app 're:^/(healthz|metrics)$'
# List / remove:
dokku google-auth:exclude my-app
dokku google-auth:unexclude my-app /api/webhooks
Excluded paths proxy straight to your app with identity headers blanked — protect them yourself (API key, HMAC signature, etc.).
Day-to-day commands
dokku google-auth:report # global + per-app status, incl. both access lists
dokku google-auth:report my-app # one app
dokku google-auth:allow --global alice@signal.org # let someone in everywhere
dokku google-auth:allow my-app alice@signal.org # …or just on one app
dokku google-auth:deny --global former@signal.org # cut someone off
dokku google-auth:disable my-app # turn SSO off for an app
dokku google-auth:logs -t # follow auth service logs (sign-ins, denials)
dokku google-auth:restart # restart the auth service
Users can check who they're signed in as at https://<any-app>/_google-auth/
and sign out at https://<any-app>/_google-auth/logout.
Things worth knowing
- HTTPS is required in practice. Google refuses plain-HTTP redirect URIs
and the session cookie is marked
Secure. Use dokku-letsencrypt as usual. (--insecure-allow-httpexists for local experiments only.) - The auth host must stay enabled. Google's callback lands on
<auth-host>, so the app serving that domain must keep google-auth enabled.google-auth:disablewarns you if you disable that app. - Sessions are per-domain. Signing in to
app-athen visitingapp-btriggers another round-trip through Google, but it's silent (already signed in) — the user just sees a quick redirect. - In-flight POSTs across an expired session get redirected to sign-in and
are replayed as GETs — the POST body is lost. That's inherent to
redirect-based SSO; keep session TTLs comfortable (e.g.
72h) if it bites. - Custom nginx location blocks in your own
nginx.conf.dfiles: plainlocation /fooprefix blocks will be shadowed by this plugin's catch-all regex location — uselocation ^~ /fooin your own snippets if you need them to win. - Non-browser clients (curl, fetch without
Accept: text/html) get a JSON401instead of a redirect chain. - Websockets work — upgrade headers are forwarded exactly like dokku's stock nginx template.
- Plugin updates:
sudo dokku plugin:update google-authrebuilds the service image and restarts the container automatically. - An app's own lists need its nginx config to be current, since that is what tells the service which app a request belongs to. The plugin rewrites the config whenever you change an app's lists (and on every deploy), so this is normally invisible — but an app that has not been deployed or touched since an upgrade falls back to the global lists until then.
Uninstall
dokku google-auth:disable <each-app>
dokku google-auth:stop
sudo dokku plugin:uninstall google-auth
sudo rm -rf /var/lib/dokku/data/google-auth
docker image rm dokku-google-auth:latest
Development
Tasks are defined in mise.toml (mise pins Go and
shellcheck):
mise install # install pinned toolchain
mise run test # Go unit tests (full OAuth flow against a fake Google)
mise run test-nginx-conf # generate nginx config + validate with real nginx in docker
mise run test-access-lists # allow/deny commands against a fake dokku layout
mise run test-help # help output shapes + every subcommand documented
mise run shellcheck # lint all plugin scripts
mise run check # everything CI would run
mise run docker-build # build the service image locally
Layout:
cmd/google-auth-proxy/ Go entrypoint
internal/authproxy/ OAuth flow, session crypto, HTTP handlers (+ tests)
functions shared bash for the plugin (config store, nginx generation)
subcommands/ dokku google-auth:* commands
install, nginx-pre-reload, core-post-deploy, post-* dokku lifecycle triggers
test/ bash tests: nginx config generation, access lists, help output
Dockerfile multi-stage build → static binary in a scratch image