Skip to content

Instantly share code, notes, and snippets.

@johnandersen777
Created August 7, 2026 02:17
Show Gist options
  • Select an option

  • Save johnandersen777/01b08c6525a089921169ba645cf0b76b to your computer and use it in GitHub Desktop.

Select an option

Save johnandersen777/01b08c6525a089921169ba645cf0b76b to your computer and use it in GitHub Desktop.

GitLab + Bluesky (ATProto) OIDC login on a Tailscale node

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.

Architecture

                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

Why this topology (the two hard lessons)

  1. GitLab's GITLAB_OMNIBUS_CONFIG env is only applied on first boot. docker compose up after editing the env does not regenerate /etc/gitlab/gitlab.rb because the config volume persists it. The container silently keeps running with default config (external_url http://gitlab/, no omniauth). Fix: append the config block directly to /srv/gitlab/config/gitlab.rb (it persists), then gitlab-ctl reconfigure. The env stays for fresh installs.

  2. ATProto OAuth rejects a client_id with a custom port. invalid_client_metadata ... Custom https: ports not allowed. ATLogin's client_id is https://<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 :8443 got metadata publicly fetchable (via funnel) but still rejected on port. Fix: ATLogin on :443 (funnel, public), GitLab on :8443 (serve, tailnet-only).

  3. GitLab's omniauth-openid-connect does not send login_hint by 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).

End-to-end flow (verified working)

  1. User clicks Bluesky on GitLab sign-in page (:8443).
  2. GitLab → ATLogin /authorize?login_hint=...&client_id=gitlab&... (via :443).
  3. ATLogin parses login_hint → handle, sends PAR (pushed authorization request) to Bluesky's auth server with client_id = https://gitlab.dorper-matrix.ts.net/client-metadata.json.
  4. Bluesky fetches and validates the client metadata, returns a request_uri.
  5. Browser → bsky.social/oauth/authorize?request_uri=... → user enters bsky password, approves.
  6. Bluesky redirects to ATLogin /atproto/callback → ATLogin exchanges code, captures handle/DID/email.
  7. ATLogin redirects to GitLab OIDC callback :8443/users/auth/openid_connect/callback with an authorization code.
  8. GitLab exchanges code at ATLogin /token (client_auth_method: "query"), validates ID token iss, and the user is signed in (auto-created because omniauth_block_auto_created_users = false).

Login identities

  • 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 maps user@domain → handle user.domain. For johnandersen777.bsky.social, hint is johnandersen777@bsky.social.

Files in this gist

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

Reproduce from scratch

# 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_password

Secrets handling

  • gitlab client secret and the ATLogin tailscale client secret are placeholders in this gist (<GITLAB_CLIENT_SECRET>, <TAILSCALE_CLIENT_SECRET>). Generate them (openssl rand -hex 16) and keep them identical in:
    1. docker-compose.ymlGITLAB_OMNIBUS_CONFIGclient_options.secret
    2. /srv/gitlab/config/gitlab.rb → same field
    3. /var/lib/atlogin/state/config.jsonsecrets.gitlab
  • ATLogin master_key is auto-generated on first run; don't set it manually.

Gotchas seen in the field

  • 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. Check tailscale funnel status shows (Funnel on) and client_id has no :port.
  • First PAR attempt logs use_dpop_nonce then 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.rb should be > 0 after append.
{
"addr": ":9411",
"issuer": "https://gitlab.dorper-matrix.ts.net",
"client_name": "ATLogin",
"master_key": "auto-generated-on-first-run",
"secrets": {
"tailscale": "<TAILSCALE_CLIENT_SECRET>",
"gitlab": "<GITLAB_CLIENT_SECRET>"
}
}
# /etc/systemd/system/atlogin.service
[Unit]
Description=ATLogin OIDC IdP for ATProto/Bluesky
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/atlogin -state-dir /var/lib/atlogin/state
Restart=always
RestartSec=3
User=atlogin
[Install]
WantedBy=multi-user.target
# GitLab CE + Bluesky OIDC — docker-compose.yml
# NOTE: GITLAB_OMNIBUS_CONFIG is only applied on FIRST boot (see README lesson 1).
# After the first `docker compose up -d`, append gitlab-omniauth.rb to
# /srv/gitlab/config/gitlab.rb and run `docker exec gitlab gitlab-ctl reconfigure`.
services:
gitlab:
image: gitlab/gitlab-ce:latest
container_name: gitlab
restart: always
# IMPORTANT: keep SHORT. A FQDN here makes Docker's embedded DNS resolve
# gitlab.dorper-matrix.ts.net to the container's own IP (172.18.x), and
# GitLab can no longer reach ATLogin (bare-IP TLS also rejects SNI).
hostname: 'gitlab'
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'https://gitlab.dorper-matrix.ts.net:8443'
gitlab_rails['nginx']['listen_port'] = 80
gitlab_rails['nginx']['listen_https'] = false
gitlab_rails['nginx']['proxy_set_headers'] = {
"X-Forwarded-Proto" => "https",
"X-Forwarded-Ssl" => "on"
}
gitlab_rails['omniauth_enabled'] = true
gitlab_rails['omniauth_allow_single_sign_on'] = ['openid_connect']
gitlab_rails['omniauth_block_auto_created_users'] = false
gitlab_rails['omniauth_providers'] = [
{
name: "openid_connect",
label: "Bluesky",
args: {
name: "openid_connect",
scope: ["openid","profile","email"],
response_type: "code",
issuer: "https://gitlab.dorper-matrix.ts.net",
discovery: true,
client_auth_method: "query",
uid_field: "sub",
# ATLogin requires login_hint; gem v0.8 merges extra_authorize_params
extra_authorize_params: {
"login_hint" => "johnandersen777@bsky.social"
},
client_options: {
identifier: "gitlab",
secret: "<GITLAB_CLIENT_SECRET>",
redirect_uri: "https://gitlab.dorper-matrix.ts.net:8443/users/auth/openid_connect/callback"
}
}
}
]
ports:
- '80:80'
- '22:22'
volumes:
- '/srv/gitlab/config:/etc/gitlab'
- '/srv/gitlab/logs:/var/log/gitlab'
- '/srv/gitlab/data:/var/opt/gitlab'
shm_size: '256m'
# Block to APPEND to /srv/gitlab/config/gitlab.rb (persists on the volume),
# then: sudo docker exec gitlab gitlab-ctl reconfigure
#
# Appending is required because the docker image only materializes
# GITLAB_OMNIBUS_CONFIG into gitlab.rb on first boot; edits to the env on an
# existing volume are ignored. This block is authoritative for running config.
# --- custom config (appended) ---
external_url 'https://gitlab.dorper-matrix.ts.net:8443'
gitlab_rails['nginx']['listen_port'] = 80
gitlab_rails['nginx']['listen_https'] = false
gitlab_rails['nginx']['proxy_set_headers'] = {
"X-Forwarded-Proto" => "https",
"X-Forwarded-Ssl" => "on"
}
gitlab_rails['omniauth_enabled'] = true
gitlab_rails['omniauth_allow_single_sign_on'] = ['openid_connect']
gitlab_rails['omniauth_block_auto_created_users'] = false
gitlab_rails['omniauth_providers'] = [
{
name: "openid_connect",
label: "Bluesky",
args: {
name: "openid_connect",
scope: ["openid","profile","email"],
response_type: "code",
issuer: "https://gitlab.dorper-matrix.ts.net",
discovery: true,
client_auth_method: "query",
uid_field: "sub",
extra_authorize_params: {
"login_hint" => "johnandersen777@bsky.social"
},
client_options: {
identifier: "gitlab",
secret: "<GITLAB_CLIENT_SECRET>",
redirect_uri: "https://gitlab.dorper-matrix.ts.net:8443/users/auth/openid_connect/callback"
}
}
}
]
#!/bin/bash
# One-shot installer for: GitLab CE (docker) + ATLogin (OIDC IdP) + tailscale
# serve/funnel routing on a Fedora node inside a Tailscale tailnet.
#
# Prereqs (do these first):
# sudo tailscale up --hostname=gitlab # MagicDNS on; node named `gitlab`
# docker installed, current user in `docker` group (or use sudo)
# Funnel enabled for this node in the Tailscale admin console (one-time link)
#
# Usage:
# cat secrets.env.example -> fill in, or pass values inline
# sudo bash setup.sh
#
# After install:
# sudo docker exec gitlab gitlab-ctl reconfigure # after first boot (see below)
# sudo docker exec gitlab cat /etc/gitlab/initial_root_password
set -euo pipefail
# ---------- config ----------
GITLAB_NS="gitlab.dorper-matrix.ts.net" # your node's ts.net FQDN
HANDLE="johnandersen777.bsky.social" # Bluesky handle for login_hint
HINT="johnandersen777@bsky.social" # login_hint passed by GitLab (user@domain)
# Secrets. Prefer env file; else generate random.
SECRETS_FILE="${SECRETS_FILE:-}"
if [[ -n "$SECRETS_FILE" ]]; then
# shellcheck disable=SC1090
. "$SECRETS_FILE"
fi
GITLAB_CLIENT_SECRET="${GITLAB_CLIENT_SECRET:-$(openssl rand -hex 16)}"
TAILSCALE_CLIENT_SECRET="${TAILSCALE_CLIENT_SECRET:-$(openssl rand -hex 16)}"
echo "==> GitLab namespace: $GITLAB_NS"
echo "==> Handle: $HANDLE (hint: $HINT)"
# ---------- 1. GitLab directories ----------
echo "==> Creating /srv/gitlab dirs"
mkdir -p /srv/gitlab/{config,logs,data}
# ---------- 2. ATLogin user + state ----------
echo "==> ATLogin user + state dir"
id atlogin >/dev/null 2>&1 || useradd -r -s /sbin/nologin atlogin
mkdir -p /var/lib/atlogin/state
install -d -o atlogin -g atlogin -m 700 /var/lib/atlogin
cat > /var/lib/atlogin/state/config.json <<EOF
{
"addr": ":9411",
"issuer": "https://$GITLAB_NS",
"client_name": "ATLogin",
"master_key": "auto-generated-on-first-run",
"secrets": {
"tailscale": "$TAILSCALE_CLIENT_SECRET",
"gitlab": "$GITLAB_CLIENT_SECRET"
}
}
EOF
chown -R atlogin:atlogin /var/lib/atlogin
# ---------- 3. Build & install ATLogin ----------
echo "==> Cloning + building atlogin"
if [[ ! -d /opt/atlogin ]]; then
git clone https://github.com/apenwarr/atlogin /opt/atlogin
fi
cd /opt/atlogin
GOFLAGS="-mod=mod" go build -o /usr/local/bin/atlogin ./cmd/atlogin
chmod 755 /usr/local/bin/atlogin
cat > /etc/systemd/system/atlogin.service <<UNIT
[Unit]
Description=ATLogin OIDC IdP for ATProto/Bluesky
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/atlogin -state-dir /var/lib/atlogin/state
Restart=always
RestartSec=3
User=atlogin
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable --now atlogin.service
echo "==> atlogin.service: $(systemctl is-active atlogin.service)"
# ---------- 4. GitLab compose ----------
echo "==> Writing docker-compose.yml"
# NOTE: hostname is intentionally SHORT. A FQDN makes Docker DNS resolve the
# ts.net name to the container's own IP and GitLab can't reach ATLogin.
cat > /srv/gitlab/docker-compose.yml <<EOF
services:
gitlab:
image: gitlab/gitlab-ce:latest
container_name: gitlab
restart: always
hostname: 'gitlab'
environment:
GITLAB_OMNIBUS_CONFIG: |
external_url 'https://$GITLAB_NS:8443'
gitlab_rails['nginx']['listen_port'] = 80
gitlab_rails['nginx']['listen_https'] = false
gitlab_rails['nginx']['proxy_set_headers'] = {
"X-Forwarded-Proto" => "https",
"X-Forwarded-Ssl" => "on"
}
gitlab_rails['omniauth_enabled'] = true
gitlab_rails['omniauth_allow_single_sign_on'] = ['openid_connect']
gitlab_rails['omniauth_block_auto_created_users'] = false
gitlab_rails['omniauth_providers'] = [
{
name: "openid_connect",
label: "Bluesky",
args: {
name: "openid_connect",
scope: ["openid","profile","email"],
response_type: "code",
issuer: "https://$GITLAB_NS",
discovery: true,
client_auth_method: "query",
uid_field: "sub",
extra_authorize_params: {
"login_hint" => "$HINT"
},
client_options: {
identifier: "gitlab",
secret: "$GITLAB_CLIENT_SECRET",
redirect_uri: "https://$GITLAB_NS:8443/users/auth/openid_connect/callback"
}
}
}
]
ports:
- '80:80'
- '22:22'
volumes:
- '/srv/gitlab/config:/etc/gitlab'
- '/srv/gitlab/logs:/var/log/gitlab'
- '/srv/gitlab/data:/var/opt/gitlab'
shm_size: '256m'
EOF
echo "==> Starting GitLab (first boot materializes config into gitlab.rb)"
cd /srv/gitlab
docker compose up -d
echo "==> Waiting for GitLab to be healthy (can take a few minutes)"
for i in $(seq 1 60); do
if sudo docker inspect gitlab --format '{{.State.Health.Status}}' 2>/dev/null | grep -q healthy; then
echo "healthy after ~${i}x10s"; break
fi
sleep 10
done
# ---------- 5. Append omniauth block (env is ignored after first boot) ----------
echo "==> Appending omniauth block to gitlab.rb (authoritative for running config)"
cat >> /srv/gitlab/config/gitlab.rb <<EOF
# --- custom config (appended) ---
external_url 'https://$GITLAB_NS:8443'
gitlab_rails['nginx']['listen_port'] = 80
gitlab_rails['nginx']['listen_https'] = false
gitlab_rails['nginx']['proxy_set_headers'] = {
"X-Forwarded-Proto" => "https",
"X-Forwarded-Ssl" => "on"
}
gitlab_rails['omniauth_enabled'] = true
gitlab_rails['omniauth_allow_single_sign_on'] = ['openid_connect']
gitlab_rails['omniauth_block_auto_created_users'] = false
gitlab_rails['omniauth_providers'] = [
{
name: "openid_connect",
label: "Bluesky",
args: {
name: "openid_connect",
scope: ["openid","profile","email"],
response_type: "code",
issuer: "https://$GITLAB_NS",
discovery: true,
client_auth_method: "query",
uid_field: "sub",
extra_authorize_params: {
"login_hint" => "$HINT"
},
client_options: {
identifier: "gitlab",
secret: "$GITLAB_CLIENT_SECRET",
redirect_uri: "https://$GITLAB_NS:8443/users/auth/openid_connect/callback"
}
}
}
]
EOF
echo "==> Reconciling (gitlab-ctl reconfigure; a few minutes)"
sudo docker exec gitlab gitlab-ctl reconfigure
# ---------- 6. Tailscale serve + funnel ----------
echo "==> tailscale serve: gitlab on 8443 (tailnet), atlogin on 443"
tailscale serve --bg --https=8443 http://127.0.0.1:80 || true
tailscale serve --bg --https=443 http://127.0.0.1:9411 || true
tailscale funnel --bg --https=443 http://127.0.0.1:9411 || true
cat > /etc/systemd/system/tailscale-serve.service <<UNIT
[Unit]
Description=Tailscale Serve (gitlab :8443) + Funnel (atlogin :443)
After=network-online.target tailscaled.service docker.service
Wants=network-online.target
Requires=tailscaled.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/tailscale serve --bg --https=8443 http://127.0.0.1:80
ExecStart=/usr/bin/tailscale serve --bg --https=443 http://127.0.0.1:9411
ExecStart=/usr/bin/tailscale funnel --bg --https=443 http://127.0.0.1:9411
[Install]
WantedBy=multi-user.target
UNIT
systemctl daemon-reload
systemctl enable tailscale-serve.service
echo
echo "DONE. Verify:"
echo " tailscale serve status # 8443 -> gitlab, 443 -> atlogin (Funnel on)"
echo " curl -s https://$GITLAB_NS/client-metadata.json"
echo " curl -s -o /dev/null -w '%{http_code}\\n' https://$GITLAB_NS:8443/users/sign_in"
echo " sudo docker exec gitlab cat /etc/gitlab/initial_root_password"
echo "If funnel was not yet approved: https://login.tailscale.com/f/funnel?node=<NODE-ID>"
#!/bin/bash
# Move sshd from port 22 to 1997 on Fedora 44 (SELinux enforcing).
#
# sshd semantics: the built-in default is Port 22. If you set ANY Port
# directive (in sshd_config or a drop-in), sshd listens on exactly those ports
# — 22 disappears automatically. So writing `Port 1997` is enough.
#
# This script is still staged for safety: it only restarts sshd AFTER the new
# port is verified listening, so you don't lock yourself out.
#
# Usage:
# sudo bash sshd-reconfig.sh # label SELinux, open firewall, set port
set -euo pipefail
NEW_PORT=1997
CONF=/etc/ssh/sshd_config.d/99-port.conf
command -v semanage >/dev/null || sudo dnf install -y policycoreutils-python-utils
command -v firewall-cmd >/dev/null || sudo dnf install -y firewalld
# SELinux: label the new port as ssh
if ! sudo semanage port -l | grep -q "^ssh_port_t .*$NEW_PORT"; then
sudo semanage port -a -t ssh_port_t -p tcp $NEW_PORT
echo "==> SELinux: added $NEW_PORT to ssh_port_t"
fi
# Firewall: open the new port
sudo firewall-cmd --permanent --add-port=$NEW_PORT/tcp >/dev/null
sudo firewall-cmd --reload >/dev/null
echo "==> firewalld: opened $NEW_PORT/tcp"
# sshd drop-in: Port 1997 (also removes default 22)
printf 'Port %s\n' "$NEW_PORT" > "$CONF"
echo "==> wrote $CONF: Port $NEW_PORT"
# Verify the config is valid BEFORE restarting.
if ! sudo sshd -t; then
echo "!! sshd -t failed; config not applied. Leaving sshd untouched."
exit 1
fi
sudo systemctl restart sshd
# Confirm 1997 is actually listening.
if sudo ss -tln | grep -q ":$NEW_PORT "; then
echo "==> sshd restarted, listening on $NEW_PORT."
echo " Verify from another shell: ssh -p $NEW_PORT <user>@<host>"
echo " sshd now listens ONLY on $NEW_PORT (default 22 removed)."
else
echo "!! $NEW_PORT not listening after restart! Check: sudo sshd -t; sudo journalctl -u sshd"
fi
# /etc/systemd/system/tailscale-serve.service
# 8443 = GitLab (tailnet-only serve)
# 443 = ATLogin (funnel -> public internet, so Bluesky can fetch client metadata)
[Unit]
Description=Tailscale Serve (gitlab :8443) + Funnel (atlogin :443)
After=network-online.target tailscaled.service docker.service
Wants=network-online.target
Requires=tailscaled.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/bin/tailscale serve --bg --https=8443 http://127.0.0.1:80
ExecStart=/usr/bin/tailscale serve --bg --https=443 http://127.0.0.1:9411
ExecStart=/usr/bin/tailscale funnel --bg --https=443 http://127.0.0.1:9411
[Install]
WantedBy=multi-user.target
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment