Self-hosted GitLab CE with single sign-on via Bluesky, no email provider, no external SaaS. Bluesky credentials are the only login. Everything runs on one Fedora node behind Tailscale.
public internet tailnet (private)
┌──────────────────────────────────┐ ┌─────────────────────────────────┐
│ Bluesky PDS (bsky.social) │ │ node: gitlab.dorper-matrix.ts.net │
│ · verifies handle via webfinger │ │ │
│ · OAuth authorize + PAR │ │ :443 tailscale funnel → atlogin │
│ · fetches our client metadata │ │ :8443 tailscale serve → gitlab │
└──────────────────┬───────────────┘ │ │
│ OAuth 2.1 + DPoP │ atlogin (:9411) OIDC IdP │
▼ │ gitlab (:80) gitlab-ce:latest │
atlogin │ │
(OIDC bridge) └─────────────────────────────────────┘
Components:
| Piece | What | Where |
|---|---|---|
| GitLab CE | docker container, omniauth OIDC | :8443 (serve, tailnet-only) |
| ATLogin | OIDC IdP that bridges to ATProto OAuth | :443 (funnel, public) |
| Tailscale serve | HTTPS proxy inside tailnet, auto LE certs | — |
| Tailscale funnel | exposes :443 to public internet |
required so Bluesky can fetch client metadata |
-
GitLab's
GITLAB_OMNIBUS_CONFIGenv is only applied on first boot.docker compose upafter editing the env does not regenerate/etc/gitlab/gitlab.rbbecause the config volume persists it. The container silently keeps running with default config (external_urlhttp://gitlab/, no omniauth). Fix: append the config block directly to/srv/gitlab/config/gitlab.rb(it persists), thengitlab-ctl reconfigure. The env stays for fresh installs. -
ATProto OAuth rejects a client_id with a custom port.
invalid_client_metadata ... Custom https: ports not allowed. ATLogin'sclient_idishttps://<host>/client-metadata.json, which bsky.social must be able to fetch and which must be on a standard port (443). Our first attempt on:8443got metadata publicly fetchable (via funnel) but still rejected on port. Fix: ATLogin on:443(funnel, public), GitLab on:8443(serve, tailnet-only). -
GitLab's omniauth-openid-connect does not send
login_hintby default; ATLogin requires it. Fix:extra_authorize_params: { "login_hint" => "handle@domain" }in the provider args (gem v0.8 merges this into the authorize request).
- User clicks Bluesky on GitLab sign-in page (
:8443). - GitLab → ATLogin
/authorize?login_hint=...&client_id=gitlab&...(via:443). - ATLogin parses
login_hint→ handle, sends PAR (pushed authorization request) to Bluesky's auth server withclient_id = https://gitlab.dorper-matrix.ts.net/client-metadata.json. - Bluesky fetches and validates the client metadata, returns a
request_uri. - Browser →
bsky.social/oauth/authorize?request_uri=...→ user enters bsky password, approves. - Bluesky redirects to ATLogin
/atproto/callback→ ATLogin exchanges code, captures handle/DID/email. - ATLogin redirects to GitLab OIDC callback
:8443/users/auth/openid_connect/callbackwith an authorizationcode. - GitLab exchanges code at ATLogin
/token(client_auth_method: "query"), validates ID tokeniss, and the user is signed in (auto-created becauseomniauth_block_auto_created_users = false).
- GitLab external URL:
https://gitlab.dorper-matrix.ts.net:8443 - ATLogin issuer:
https://gitlab.dorper-matrix.ts.net - OIDC client:
identifier: gitlab,client_auth_method: query,uid_field: sub - login_hint:
<bluesky-handle>@<domain>— ATLogin mapsuser@domain→ handleuser.domain. Forjohnandersen777.bsky.social, hint isjohnandersen777@bsky.social.
| File | Purpose |
|---|---|
setup.sh |
one-shot installer: dirs, compose, atlogin build+unit, serve/funnel units |
docker-compose.yml |
GitLab service + GITLAB_OMNIBUS_CONFIG |
gitlab-omniauth.rb |
block appended to /srv/gitlab/config/gitlab.rb |
atlogin-config.json |
ATLogin state config |
atlogin.service |
systemd unit for ATLogin |
tailscale-serve.service |
persist serve (:8443 gitlab) + funnel (:443 atlogin) across boots |
sshd-reconfig.sh |
Fedora 44 SELinux+firewalld-safe move of sshd 22 → 1997 |
# 0. On the Fedora node, as a sudo user (here: johnandersen777):
# tailscale up, MagicDNS on, node renamed: tailscale set --hostname=gitlab
# Docker installed.
# 1. (Optional) sshd 22 → 1997
sudo bash sshd-reconfig.sh # staged: keeps 22 until 1997 verified
# 2. GitLab
sudo bash setup.sh --secrets-file /tmp/my-secrets.env # fills <SECRET> tokens
# 3. Tailscale admin: enable Funnel for this node
# https://login.tailscale.com/f/funnel?node=<NODE-ID> (one-time)
# 4. Append omniauth block + reconfigure (env is ignored after first boot)
sudo python3 append-config.py 2>/dev/null || sudo cat gitlab-omniauth.rb >> /srv/gitlab/config/gitlab.rb
sudo docker exec gitlab gitlab-ctl reconfigure
# 5. First-run root password
sudo docker exec gitlab cat /etc/gitlab/initial_root_passwordgitlabclient secret and the ATLogintailscaleclient secret are placeholders in this gist (<GITLAB_CLIENT_SECRET>,<TAILSCALE_CLIENT_SECRET>). Generate them (openssl rand -hex 16) and keep them identical in:docker-compose.yml→GITLAB_OMNIBUS_CONFIG→client_options.secret/srv/gitlab/config/gitlab.rb→ same field/var/lib/atlogin/state/config.json→secrets.gitlab
- ATLogin
master_keyis auto-generated on first run; don't set it manually.
curl -X POST /users/auth/openid_connect→ 302 back to sign-in = CSRF rejection, not a config error. OmniAuth 2.0 requires the session cookie +authenticity_token. Use a real browser.invalid_client_metadata= bsky can't fetch or rejects client_id. Checktailscale funnel statusshows(Funnel on)and client_id has no:port.- First PAR attempt logs
use_dpop_noncethen retries — normal. - If sign-in page shows no Bluesky button, the omniauth block is not in
gitlab.rb(see lesson 1).grep -c openid_connect /srv/gitlab/config/gitlab.rbshould be > 0 after append.