Introduction

The Single Sign On stack has been the bane of my existence since I first fell down the rabbit hole. I think I’ve finally got it in a good place, and hopefully with this page, I can help many others (and probably myself in the future) when it comes to setting this up. I have been doing this with FreeIPA and Keycloak for a while. When I did that setup, I did a write up on it which you can find here. I took the time to explain a lot of the ideas and concepts I now view as “more basic”, so if you’re just starting out, it may be worth looking at as well. Anything I say here supersedes anything I said there.

I want to start off with what the services I have chosen are, and why I wish to use them.

Keycloak

Keycloak is an awesome, yet highly complex for the uninitiated like myself, piece of software which allows you to tie multiple credential stores (LDAP, AD, etc.) into a single place which can provide OIDC and SAML session handling. These technologies facilitate Single Sign On (SSO) so that if you have, for example, GitLab (SAML), Nextcloud (OIDC), Outline (OIDC), and Apache Guacamole (OIDC) configured, when you are not logged in and you go to Nextcloud, you will be prompted to log into Keycloak one time, then you can access GitLab, Outline, and Guacamole without needing to log in again. Furthermore, it eases the administrative burdon of having to manage accounts for all of these services individually. You just create the account in Keycloak, it replicates to your LDAP/AD/etc. and you’re good to go.

Mariadb

This is a very common database. Without an external database, Keycloak stores all of its data in an H2 database located at /opt/keycloak/data. This works fine, but there doesn’t appear to be a super great way to back up H2 databases without stopping Keycloak, as far as I can tell. On the other hand, I already have a descent workflow for automatically backing up mariadb databases in containers.

OpenLDAP

If you’re unfamiliar with LDAP, it’s just a protocol that can be used to store information about users, computers, etc. In this case, we’re using it as the back-end to store our users. It’s worth noting that you can use Keycloak by itself without LDAP, but I wanted LDAP for other things and figured it was potentially a little better to separate out the task of “holding account” from Keycloak.

I’ve been using FreeIPA in a VM for this task for a while, but I really wanted to throw everything in Containers. Docker has compatibility issues with cgroups v2, and as a result, FreeIPA doesn’t seem to work. If I was hell-bent on FreeIPA, I could switch over to podman, which does support cgroupsv2, but I just chose to use OpenLDAP instead. The downside to choosing OpenLDAP over FreeIPA is that the FreeIPA WebUI is very nice. It lets you fully manage your LDAP environment without having to know anything about LDIF (think LDAP config files). OpenLDAP on the other hand, well, you’re likely going to need to get your hands at least a little dirty if you want to do anything mildly complex. Thankfully, Keycloak can push its accounts onto LDAP, which means you can largely avoid getting too deep into the weeds.

Let’s build this thing!

With that out of the way, let’s get to the reason you are here; the docker-compose.yml.

version: '3.9'

services:
  db:
    image: mariadb:10
    environment:
      MARIADB_RANDOM_ROOT_PASSWORD: yes
      MARIADB_USER:                 ${MARIADB_USER}
      MARIADB_PASSWORD:             ${MARIADB_PASSWORD}
      MARIADB_DATABASE:             ${MARIADB_DATABASE}
    restart: unless-stopped
    volumes:
      - db:/var/lib/mysql
  keycloak:
    image: quay.io/keycloak/keycloak:latest
    command: start --optimized
    container_name: keycloak
    depends_on:
      - db
      - openldap
    environment:
      KC_HOSTNAME:             ${KC_HOSTNAME}
      KC_PROXY:                edge
      KEYCLOAK_ADMIN:          ${KEYCLOAK_ADMIN}
      KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD}
      KC_DB:                   mariadb
      KC_DB_URL_HOST:          db
      KC_DB_URL_PORT:          3306
      KC_DB_URL_DATABASE:      ${MARIADB_DATABASE}
      KC_DB_USERNAME:          ${MARIADB_USER}
      KC_DB_PASSWORD:          ${MARIADB_PASSWORD}
    networks:
      - default
      - rp-network
    #ports:
    #  - 8080:8080
    #  - 8443:8443
    restart: unless-stopped
  openldap:
    image: bitnami/openldap:2.6-debian-10
    environment:
      LDAP_ALLOW_ANON_BINDING: no
      LDAP_ADMIN_USERNAME:     ${LDAP_ADMIN_USERNAME}
      LDAP_ADMIN_PASSWORD:     ${LDAP_ADMIN_PASSWORD}
      LDAP_CUSTOM_LDIF_DIR:    /ldifs
      #LDAP_USERS:             bob,tom
      #LDAP_PASSWORDS:         password1, password2
      LDAP_ROOT:               ${LDAP_ROOT}
    restart: unless-stopped
    volumes:
      - ./ldifs:/ldifs:ro
      - ldap:/bitnami/openldap

networks:
  rp-network:
    name: rp-network
    external: true

volumes:
  db:
  ldap:

To quickly cover this docker-compose.yml, we are starting the 3 services I introduced at the top. Of special note, I want to draw attention to the rp-network within the keycloak service. In my network, I have an nginx reverse proxy which handles my web traffic. By specifying the networks in the way that I have, a private network will be spun up for this stack, but keycloak will also be placed into the reverse proxy network, which will allow me to configure nginx to handle its traffic. I am not going to cover that on this page, but feel free to ask if you want more information. If you don’t want to set it up like that, delete all the network stuff and uncomment the ports under the keycloak service.

Under the openldap service, you will noticed LDAP_CUSTOM_LDIF_DIR defines a custom volume mount. This is because I’ve chosen to bootstrap my LDAP with a few LDIF’s instead of using the commented variables to define users. These environmental variables are mutually exclusive and if LDAP_CUSTOM_LDIF_DIR is defined, the LDAP_USERS will not be created. You way wish to do things this way if you would like additional organization, groups, etc. defined in LDAP, but this shouldn’t be incredibly if you have a simple setup.

It would be lame of be to just be like “You can use LDIFs! I do!” and not at least throw in a small bit about how that works. With the docker-compose.yml above, you can create a file like ./ldifs/bootstrap.ldif within that same folder and with contents something like this:

# example.com
dn: dc=example,dc=com
changetype: add
objectClass: dcObject
objectClass: organization
dc: example
o: Example Company

# users, lab.bytepen.com
dn: ou=users,dc=example,dc=com
changetype: add
objectClass: organizationalUnit
ou: users

# groups, lab.bytepen.com
dn: ou=groups,dc=example,dc=com
changetype: add
objectClass: organizationalUnit
ou: groups

This LDIF will create our organization, an an organizational unit for users and one for groups. I’ll be using Keycloak for everything else, so this is about as complex as I’ve made mine so far. Keep in mind that this file MUST be in place before you start as the files in /ldifs are only read the very first boot. If you miss it, you will need to add it with ldapmodify, and there may be issues depending on what is in your LDAP already.

You will notice a large amount of variables within the file above, I tend to prefer to keep anything remotely secret in a .env file stored right next to the docker-compose.yml. When you do this, the .env file is automatically read-in whenever you start the Docker Compose stack. The .env file may look something like this:

KC_HOSTNAME=keycloak.example.com
KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=change_me
LDAP_ADMIN_USERNAME=admin
LDAP_ADMIN_PASSWORD=change_me
LDAP_ROOT=dc=example,dc=com
MARIADB_USER=keycloak
MARIADB_PASSWORD=change_me
MARIADB_DATABASE=keycloak