Matrix Homeserver with MAS, Keycloak & Tailscale

A deep-dive into setting up a self-hosted Matrix homeserver with modern authentication, SSO via Keycloak, and QR code login support—all exposed securely through Tailscale Funnel.

The Goal

Get a Matrix homeserver running on a Raspberry Pi that:

  • Is publicly accessible via Tailscale Funnel
  • Uses Matrix Authentication Service (MAS) for modern OIDC-based auth
  • Integrates with Keycloak for SSO
  • Supports QR code login from Element iOS/Android
  • Federates with other Matrix servers

Architecture Overview

Internet
Tailscale Funnel (HTTPS :443)
┌─────────────────────────────────────────────────────────┐
│  Raspberry Pi (thinkmeshmatrix.tail16ecc2.ts.net)       │
│                                                         │
│  ┌─────────────┐     ┌─────────────┐                   │
│  │ nginx-proxy │────▶│   Synapse   │                   │
│  │   (:8090)   │     │   (:8008)   │                   │
│  └──────┬──────┘     └─────────────┘                   │
│         │                                               │
│         ├────────────▶┌─────────────┐                  │
│         │             │     MAS     │                   │
│         │             │   (:8080)   │                   │
│         │             └──────┬──────┘                   │
│         │                    │                          │
│         │             ┌──────▼──────┐                  │
│         │             │ mas-postgres │                  │
│         │             └─────────────┘                   │
│         │                                               │
│         └────────────▶┌─────────────┐                  │
│                       │  Keycloak   │                   │
│                       │   (:8080)   │                   │
│                       └─────────────┘                   │
└─────────────────────────────────────────────────────────┘

The Journey (and the Hiccups)

1. Initial Tailscale Setup

Started by installing Tailscale directly on the Raspberry Pi running Synapse and enabling Funnel for public HTTPS access:

tailscale up --ssh
tailscale funnel 8090

Changed the Pi’s hostname to thinkmeshmatrix to reflect its purpose.

2. Federation Woes

Initial server was configured with server_name: matrix.local—which obviously can’t federate since remote servers can’t verify it.

Hiccup #1: Empty federation_domain_whitelist: [] was blocking ALL outbound federation. The fix was to comment out that line entirely.

Hiccup #2: Matrix federation uses port 8448 by default, but Tailscale Funnel serves on 443. Fixed by adding serve_server_wellknown: true to Synapse config, which tells remote servers to use port 443.

Had to reset the server with a new server_name:

server_name: "thinkmeshmatrix.tail16ecc2.ts.net"

3. Setting Up MAS (Matrix Authentication Service)

MAS provides modern OIDC-based authentication for Matrix, enabling features like:

  • Delegated authentication to external providers
  • Fine-grained session management
  • QR code login (MSC4108)

Hiccup #3: MAS requires PostgreSQL, not SQLite. Deployed a postgres:16-alpine container:

docker run -d --name mas-postgres \
  --network matrix-net \
  -e POSTGRES_USER=mas \
  -e POSTGRES_PASSWORD=maspassword \
  -e POSTGRES_DB=mas \
  -v mas-postgres-data:/var/lib/postgresql/data \
  --restart unless-stopped \
  postgres:16-alpine

4. Nginx Reverse Proxy Routing

The trickiest part was getting nginx to route requests correctly between Synapse, MAS, and Keycloak. Key insight: certain Matrix endpoints need to go to MAS, not Synapse:

# MAS endpoints
location /.well-known/openid-configuration { proxy_pass http://mas; }
location /oauth2/ { proxy_pass http://mas; }
location /authorize { proxy_pass http://mas; }
location /_matrix/client/v3/login { proxy_pass http://mas; }
location /complete-compat-sso/ { proxy_pass http://mas; }

# Keycloak endpoints  
location /realms/ { proxy_pass http://keycloak; }

# Everything else to Synapse
location / { proxy_pass http://synapse; }

Hiccup #4: Kept getting “No Such Resource” errors during SSO flow. Turned out several MAS endpoints were missing from nginx config: /complete-compat-sso/, /link, /consent, /device.

5. Keycloak Integration

Set up Keycloak as the upstream identity provider for MAS. This allows using Keycloak’s user management and potentially federating with other identity providers later.

Hiccup #5: MAS was redirecting users to http://keycloak:8080/... (internal Docker hostname) instead of the public URL. Fixed by:

  1. Adding Keycloak routes to nginx
  2. Configuring Keycloak with KC_HOSTNAME:
docker run -d --name keycloak \
  -e KC_HOSTNAME=thinkmeshmatrix.tail16ecc2.ts.net \
  -e KC_HTTP_ENABLED=true \
  -e KC_PROXY_HEADERS=xforwarded \
  ...
  1. Updating MAS config to use public URL for issuer

Hiccup #6: MAS cached the old Keycloak discovery document. Had to change the provider ID to force a refresh.

6. User Provisioning Issues

Hiccup #7: “Localpart not available” error when trying to register via SSO. The user existed in Synapse’s database from a failed previous attempt, but not in MAS. Had to manually clean up:

# Stop Synapse
docker stop synapse

# Clean up orphaned user data
sudo sqlite3 /home/pi/matrix-synapse/homeserver.db \
  "DELETE FROM users WHERE name = '@username:server';"
sudo sqlite3 /home/pi/matrix-synapse/homeserver.db \
  "DELETE FROM profiles WHERE full_user_id LIKE '%username%';"

# Clean up MAS
docker exec mas-postgres psql -U mas -d mas -c \
  "DELETE FROM upstream_oauth_authorization_sessions; DELETE FROM upstream_oauth_links;"

docker start synapse

7. Synapse Configuration Evolution

Hiccup #8: Synapse 1.136+ deprecated experimental_features.msc3861 in favor of a stable matrix_authentication_service config block:

# Old (deprecated)
experimental_features:
  msc3861:
    enabled: true
    issuer: https://...
    
# New (stable)
matrix_authentication_service:
  enabled: true
  endpoint: http://mas:8080
  secret: "shared-secret"

8. QR Code Login (MSC4108)

The final boss: getting QR code login working for Element iOS.

Hiccup #9: MSC4108 wasn’t being advertised. Had to add to Synapse config:

experimental_features:
  msc4108_enabled: true

Hiccup #10: The rendezvous endpoint was incorrectly routed to MAS instead of Synapse. Synapse has a built-in rendezvous server at /_synapse/client/rendezvous. Removed the incorrect nginx route and let it fall through to Synapse.

Testing the rendezvous endpoint:

curl -X POST "https://server/_matrix/client/unstable/org.matrix.msc4108/rendezvous" \
  -H "Content-Type: text/plain" -d 'test'
# Returns: {"url":"https://server/_synapse/client/rendezvous/SESSION_ID"}

Final Configuration

Synapse (homeserver.yaml)

server_name: "thinkmeshmatrix.tail16ecc2.ts.net"
public_baseurl: "https://thinkmeshmatrix.tail16ecc2.ts.net/"

serve_server_wellknown: true
allow_public_rooms_over_federation: true

matrix_authentication_service:
  enabled: true
  endpoint: http://mas:8080
  secret: "shared-secret"
  account_management_url: "https://thinkmeshmatrix.tail16ecc2.ts.net/account"

experimental_features:
  msc4108_enabled: true

MAS (config.yaml)

http:
  public_base: https://thinkmeshmatrix.tail16ecc2.ts.net/
  issuer: https://thinkmeshmatrix.tail16ecc2.ts.net/

matrix:
  homeserver: thinkmeshmatrix.tail16ecc2.ts.net
  endpoint: http://synapse:8008
  secret: "shared-secret"

upstream_oauth2:
  providers:
  - id: keycloak-provider
    issuer: https://thinkmeshmatrix.tail16ecc2.ts.net/realms/matrix
    client_id: mas
    client_secret: "keycloak-client-secret"
    claims_imports:
      localpart:
        action: require
        template: "{{ user.preferred_username }}"

experimental:
  qr_login:
    enabled: true

Data Persistence & Recovery

All containers configured with:

  • --restart unless-stopped for automatic recovery after power outages
  • Proper volume mounts for data persistence:
ServiceVolume
Synapse/home/pi/matrix-synapse/data
MAS/home/pi/mas/config
MAS-PostgresDocker volume mas-postgres-data
Keycloak/home/pi/keycloak-data/opt/keycloak/data
nginx-proxy/home/pi/nginx-proxy/etc/nginx/conf.d

Lessons Learned

  1. Docker networking is tricky - Internal hostnames (keycloak:8080) work for server-to-server communication but not for browser redirects. Always use public URLs in OAuth flows.

  2. OIDC discovery caching - MAS caches discovery documents. When you change upstream provider URLs, you may need to force a refresh by changing provider IDs or restarting.

  3. Database cleanup matters - Failed registration attempts can leave orphaned records in Synapse’s database that block future attempts. Clean up both the users and profiles tables.

  4. nginx routing order - More specific routes must come before catch-all routes. The MSC4108 rendezvous endpoint needs to go to Synapse, not MAS.

  5. Synapse evolves fast - Configuration that works in one version may be deprecated in the next. The move from experimental_features.msc3861 to matrix_authentication_service caught me off guard.

Current Status

  • ✅ Matrix homeserver running at thinkmeshmatrix.tail16ecc2.ts.net
  • ✅ Federation working with matrix.org and other servers
  • ✅ SSO via Keycloak operational
  • ✅ QR code login (MSC4108) functional
  • ✅ Automatic restart after power outages
  • ✅ Data persistence across container restarts

Resources