Self-Hosting Authentik: Add SSO and MFA to All Your Apps Behind a Reverse Proxy

Self-Hosting Authentik: Add SSO and MFA to All Your Apps Behind a Reverse Proxy

We earn commissions when you shop through the links on this page, at no additional cost to you. Learn more.

If you're running a stack of self-hosted apps — Jellyfin, Nextcloud, Gitea, Uptime Kuma, Portainer — you already know the pain: every service has its own login screen, its own password, and absolutely no shared session. Authentik fixes all of that. It's an open-source Identity Provider (IdP) that gives you Single Sign-On (SSO), LDAP, SAML, OAuth2/OIDC, and rock-solid MFA, all from a single Docker Compose deployment you fully control.

I've been running Authentik in front of my entire homelab for about a year now. Once it clicks, you'll wonder how you lived without it. This tutorial walks you through deploying Authentik with Docker Compose, wiring it up behind a Caddy reverse proxy, and protecting a real app (Portainer) with a forward-auth proxy provider — including enforcing TOTP-based MFA for every login.

What You'll Need

Step 1 — Deploy Authentik with Docker Compose

Authentik needs PostgreSQL and Redis alongside its own server and worker containers. The project ships an official docker-compose.yml you can download directly, but I prefer maintaining my own so I keep it in a single docker-compose.yml alongside my other services. Below is a clean, production-ready version.

First, generate the two secrets you'll need before touching any YAML:

# Generate a secure secret key for Authentik
openssl rand -base64 36

# Generate a strong PostgreSQL password
openssl rand -base64 24

Save those values — you'll drop them into the Compose file in a moment. Now create your project directory and write the Compose file:

mkdir -p /opt/authentik && cd /opt/authentik

cat > docker-compose.yml <<'EOF'
version: "3.9"

services:
  postgresql:
    image: docker.io/library/postgres:16-alpine
    container_name: authentik-postgres
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d authentik -U authentik"]
      start_period: 20s
      interval: 30s
      retries: 5
      timeout: 5s
    volumes:
      - database:/var/lib/postgresql/data
    environment:
      POSTGRES_PASSWORD: ${PG_PASS}
      POSTGRES_USER: authentik
      POSTGRES_DB: authentik
    networks:
      - authentik-internal

  redis:
    image: docker.io/library/redis:7-alpine
    container_name: authentik-redis
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
      start_period: 20s
      interval: 30s
      retries: 5
      timeout: 3s
    volumes:
      - redis:/data
    networks:
      - authentik-internal

  server:
    image: ghcr.io/goauthentik/server:2024.12
    container_name: authentik-server
    restart: unless-stopped
    command: server
    environment:
      AUTHENTIK_REDIS__HOST: redis
      AUTHENTIK_POSTGRESQL__HOST: postgresql
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
      AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
    volumes:
      - ./media:/media
      - ./custom-templates:/templates
    ports:
      - "9000:9000"
      - "9443:9443"
    depends_on:
      postgresql:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - authentik-internal
      - proxy

  worker:
    image: ghcr.io/goauthentik/server:2024.12
    container_name: authentik-worker
    restart: unless-stopped
    command: worker
    environment:
      AUTHENTIK_REDIS__HOST: redis
      AUTHENTIK_POSTGRESQL__HOST: postgresql
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - ./media:/media
      - ./certs:/certs
      - ./custom-templates:/templates
    depends_on:
      postgresql:
        condition: service_healthy
      redis:
        condition: service_healthy
    networks:
      - authentik-internal

volumes:
  database:
  redis:

networks:
  authentik-internal:
    internal: true
  proxy:
    external: true
EOF

Then create your .env file with the secrets you generated:

cat > .env <<'EOF'
PG_PASS=REPLACE_WITH_YOUR_POSTGRES_PASSWORD
AUTHENTIK_SECRET_KEY=REPLACE_WITH_YOUR_SECRET_KEY
EOF

# Lock down the .env file
chmod 600 .env
Watch out: The proxy network must already exist before you run docker compose up. If your Caddy container uses an external network called proxy, create it with docker network create proxy if it doesn't exist yet. If you named it differently, update the Compose file to match.

Now bring everything up:

docker compose up -d

# Watch the logs until the server is healthy
docker compose logs -f server

After 30–60 seconds you should see Starting HTTP server… in the server logs. Authentik listens on port 9000 (HTTP) and 9443 (HTTPS).

Step 2 — Point Caddy at Authentik

I prefer Caddy because automatic HTTPS with Let's Encrypt is zero-configuration. Add this block to your Caddyfile:

auth.yourdomain.com {
    reverse_proxy authentik-server:9000
}

# Forward-auth snippet — import this in any site block you want protected
(authentik_forward_auth) {
    forward_auth authentik-server:9000 {
        uri /outpost.goauthentik.io/auth/caddy
        copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email
        trusted_proxies private_ranges
    }
}

To protect a service — say Portainer running on port 9001 — add the import to its site block:

portainer.yourdomain.com {
    import authentik_forward_auth
    reverse_proxy portainer:9001
}

Reload Caddy with docker exec caddy caddy reload --config /etc/caddy/Caddyfile and Caddy will automatically obtain certificates for both subdomains.

Tip: If you're using Nginx Proxy Manager instead of Caddy, the equivalent is adding a custom location block with auth_request pointing to http://authentik-server:9000/outpost.goauthentik.io/auth/nginx. Authentik's documentation has a copy-paste NPM snippet — it works exactly as described there.

Step 3 — First-Time Setup and Creating Your Admin Account

Navigate to https://auth.yourdomain.com/if/flow/initial-setup/. Authentik will prompt you to create the default akadmin user and set a password. Do this immediately after deployment — the endpoint is unprotected until you complete it.

Once you're in the admin interface at https://auth.yourdomain.com/if/admin/, the key areas to explore are:

Step 4 — Create a Proxy Provider and Application for Portainer

In the admin panel:

  1. Go to Providers → Create → Proxy Provider.
  2. Name it Portainer Proxy, set mode to Forward auth (single application).
  3. Set External host to https://portainer.yourdomain.com.
  4. Leave the authentication flow as the default default-authentication-flow for now.
  5. Save, then go to Applications → Create.
  6. Name it Portainer, set the provider to the one you just created, and set Launch URL to https://portainer.yourdomain.com.
  7. Finally, go to Outposts, edit the default embedded outpost, and add the Portainer application to it.

Now visit https://portainer.yourdomain.com. You'll be redirected to the Authentik login screen. After authenticating, you land on Portainer — single sign-on working in under five minutes of UI work.

Step 5 — Enforce TOTP MFA for All Users

Go to Flows & Stages → Stages → Create and add an Authenticator TOTP Stage. Name it totp-setup. Then edit the default authentication flow (default-authentication-flow) and insert a Authenticator Validation Stage between the password stage and the consent stage. Set it to require any configured TOTP device.

The next time a user logs in without a TOTP device enrolled, Authentik automatically walks them through the enrollment flow — it displays a QR code compatible with Google Authenticator, Aegis, or Bitwarden Authenticator. Once enrolled, every subsequent login requires the six-digit code. There's no per-app configuration needed; the flow applies to every application in your Authentik instance.

Hosting This on a VPS?

Authentik genuinely needs memory — plan for at least 2 GB RAM dedicated to it under light load, 4 GB if you're running several concurrent users. I run mine on a DigitalOcean Droplet alongside Caddy and a handful of other services without issues. DigitalOcean's $24/month 4 vCPU / 8 GB Droplet is the sweet spot for a full homelab-in-the-cloud setup.

DigitalOcean

Gotchas I Hit Along the Way

What to Do Next

With the core setup working, the next logical step is wiring up OAuth2/OIDC for apps that support it natively — Gitea, Nextcloud, and Grafana all have first-class OIDC support and will respect group memberships from Authentik, letting you control access at the group level from one place. After that, look at Authentik's LDAP outpost: it lets legacy apps that only speak LDAP authenticate against Authentik users without any code changes on the app side.

If you want to lock things down further, pair Authentik with a Cloudflare Tunnel so none of your ports are exposed to the public internet at all — Authentik handles authentication, Cloudflare handles the network edge, and you sleep better at night.

Discussion