Keycloak 17.0.0 with TLS in Docker compose behind Envoy proxy

thumbnail

I wanted to look into Keycloak.X for quite a while. Keycloak.X is a lighter, faster, easier, more scalable, more cloud-native solution than the—now legacy—WildFly based Keycloak.

Keycloak.X is now officially known as Keycloak 17.0.0, the first official Quarkus-based version. It’s been released a few days ago[1] and so it was the right time to look at it.

I’m going to show you how I run Keycloak 17.0.0 with TLS behind Envoy proxy with Docker Compose. I’ve blogged about Keycloak and Keycloak behind Envoy before so this article is a recap of some of the previous articles from this blog.

Let’s go.

§directory structure

Here’s what we are dealing with:

[rad] keycloak-compose (keycloak-17) $ tree -a .
.
├── .docker
│   └── keycloak
│       └── Dockerfile
├── compose.yml
└── etc
    └── envoy
        └── envoy-keycloak.yaml

§Keycloak Dockerfile

Quarkus-based Keycloak 17.0.0 cannot be started without executing the build step. From Keycloak documentation[2]:

The build command is responsible for producing an immutable and optimized server image, which is similar to building a container image. In addition to persisting any build option you have set, this command also performs a series of optimizations to deliver the best runtime when starting and running the server. As a result, a lot of processing that would usually happen when starting and running the server is no longer necessary and the server can start and run faster.

There is no way to get away from executing build. We can use the start –auto-build flag (also documented in 2), but one way or another, we have to build it. The –auto-build option takes some time to execute on every start and building a custom image helps us shave some time off on subsequent starts.

I guessed that I wanted an optimized image so I opted in for building my own Docker image.

Building a custom Docker image is documented here[3].

My Dockerfile (.docker/keycloak/Dockerfile) looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
FROM quay.io/keycloak/keycloak:17.0.0 as builder

ENV KC_METRICS_ENABLED=true
ENV KC_FEATURES=token-exchange
ENV KC_DB=postgres
RUN /opt/keycloak/bin/kc.sh build

FROM quay.io/keycloak/keycloak:17.0.0
COPY --from=builder /opt/keycloak/lib/quarkus/ /opt/keycloak/lib/quarkus/
WORKDIR /opt/keycloak
ENV KEYCLOAK_ADMIN=admin
ENV KEYCLOAK_ADMIN_PASSWORD=admin
# change these values to point to a running postgres instance
ENV KC_DB_URL=jdbc:postgresql://postgres:5432/keycloak?ssl=allow
ENV KC_DB_USERNAME=keycloak
ENV KC_DB_PASSWORD=keycloak
ENV KC_HOSTNAME=idp-dev.gruchalski.com
ENV KC_HOSTNAME_STRICT=false
ENV KC_HTTP_ENABLED=true
ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "start"]

Let’s go through some of its most interesting aspects.

The builder stage configures the database provider, here Postgres, and executes kc.sh build. This is where the optimized build is created.

The second stage uses the builder stage and simply copies the build output into the final container. If you have looked at the original Keycloak documentation3, you have probably noticed that I do not generate a certificate here. I am putting Keycloak behind Envoy, which will terminate TLS for me. I do not need a certificate.

The new thing in 17.0.0 is the use of KEYCLOAK_ADMIN and KEYCLOAK_ADMIN_PASSWORD environment variables. Prior to 17.0.0, to create an initial administrator account, we had to execute the /opt/jboss/keycloak/bin/add-user-keycloak.sh. script and pass -u user -p pass arguments. If you read some of my previous article, you maybe remember this command:

1
2
3
4
5
docker exec dev_keycloak \
    /opt/jboss/keycloak/bin/add-user-keycloak.sh \
    -u admin \
    -p admin \
&& docker restart dev_keycloak

A note of caution: in a real deployment, you’d not store those credentials directly in the Dockerfile. You’d pass them from outside. I kept them here for brevity only.

Three other relevant notes here:

  • KC_HOSTNAME: configures the hostname on which Keycloak is intended to be running,
  • KC_HOSTNAME_STRICT: because I am putting Keycloak behind Envoy, I set this to false; in the Docker Compose setup, Envoy will be communicating with Keycloak using the keycloak hostname, this setting disables Keycloak hostname verification,
  • KC_HTTP_ENABLED: is set to true because Envoy is terminating TLS, I don’t need TLS termination directly on Keycloak.

Save the contents of the Dockerfile in .docker/keycloak/Dockerfile and build the Docker image:

1
2
3
cd .docker/keycloak
docker build -t local/keycloak:17.0.0 .
cd -

§envoy configuration

The etc/envoy/envoy-keycloak.yaml file is exactly the same as in my previous blog post[4], except of the domain name.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
static_resources:
  listeners:
  - address:
      socket_address:
        address: 0.0.0.0
        port_value: 443
    listener_filters:
    - name: "envoy.filters.listener.tls_inspector"
    filter_chains:
    - filter_chain_match:
        server_names:
        - idp-dev.gruchalski.com
      filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          codec_type: AUTO
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: keycloak
              domains:
              - "*"
              routes:
              - match:
                  prefix: "/"
                route:
                  cluster: proxy-domain1
          http_filters:
          - name: envoy.filters.http.router
      transport_socket:
        name: envoy.transport_sockets.tls
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
          common_tls_context:
            tls_certificates:
            - certificate_chain:
                filename: /etc/envoy/certificates/idp-dev.gruchalski.com.crt
              private_key:
                filename: /etc/envoy/certificates/idp-dev.gruchalski.com.key
  clusters:
  - name: proxy-domain1
    type: STRICT_DNS
    lb_policy: ROUND_ROBIN
    connect_timeout: 10s
    load_assignment:
      cluster_name: proxy-domain1
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: keycloak
                port_value: 8080

This configuration will match any request to https://idp-dev.gruchalski.com, terminate TLS using a certificate and key from /etc/envoy/certificates/* and forward to proxy-domain1 cluster, which will forward the request to keycloak:8080, where keycloak is Keycloak’s hostname in the Docker Compose configuration.

§certificates

Keycloak is configured to run on idp-dev.gruchalski.com. As in, once running, I will be able to use Keycloak by going to https://idp-dev.gruchalski.com. In order to do so, I’m adding the following line to my /etc/hosts file:

127.0.0.1       idp-dev.gruchalski.com

I need certificates. Because I am using a regular browser, I preferably want to have certificates that are by default trusted by my operating system. The obvious choice is Let’s Encrypt. I have written before about using Let’s Encrypt certificates for local development[5]. Read that article to find out more.

Long story short, my DNS is managed in Route 53 and I can use Route 53 API to automate the dns-01 challenge to obtain certificates for my local deployment. To do so, I’m using the LEGO client:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
cd etc/envoy
docker run --rm \
    -v $(pwd):/lego \
    -v ${HOME}/.aws/credentials:/root/.aws/credentials \
    -e AWS_PROFILE=lego \
    -ti goacme/lego \
    --accept-tos \
    --domains=idp-dev.gruchalski.com \
    --server=https://acme-v02.api.letsencrypt.org/directory \
    --email=radek@gruchalski.com \
    --path=/lego \
    --dns=route53 run

When the command finishes, the directory structure looks like this:

[rad] keycloak-compose (keycloak-17) $ tree -a .
.
├── .docker
│   └── keycloak
│       └── Dockerfile
├── compose.yml
└── etc
    └── envoy
        ├── certificates
        │   ├── idp-dev.gruchalski.com.crt
        │   ├── idp-dev.gruchalski.com.issuer.crt
        │   ├── idp-dev.gruchalski.com.json
        │   └── idp-dev.gruchalski.com.key
        └── envoy-keycloak.yaml

I’m ready to start the Compose setup.

§docker compose

My compose.yml looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
version: '3.9'

networks:
  keycloak-internal:
    name: keycloak-internal
  keycloak-public:
    name: keycloak-public

services:
  envoy:
    image: envoyproxy/envoy:v1.21.0
    restart: unless-stopped
    command: /usr/local/bin/envoy -c /etc/envoy/envoy-keycloak.yaml -l debug
    ports:
      - 443:443
      - 8001:8001
    volumes:
      - type: bind
        source: ./etc/envoy
        target: /etc/envoy
    networks:
        - keycloak-internal
        - keycloak-public

  postgres:
    image: postgres:13.2
    command: -c ssl=off
    restart: unless-stopped
    environment:
      POSTGRES_DB: keycloak
      POSTGRES_USER: keycloak
      POSTGRES_PASSWORD: keycloak
    networks:
      - keycloak-internal
      
  keycloak:
    depends_on:
      - postgres
    container_name: dev_keycloak
    image: local/keycloak:17.0.0
    restart: unless-stopped
    networks:
      - keycloak-internal

You can see that:

  • I bind mount the etc/envoy directory in the Envoy container, this directory contains the envoy-keycloak.yaml file and the certificates I got from Let’s Encrypt,
  • the Postgres database, username, and password match the values from the Dockerfile.

Start everything with:

1
docker compose -f compose.yml up

§that’s it

Keycloak 17.0.0 running locally in Docker Compose, with TLS, behind Envoy proxy.

Next step is to bring SPIs back into this setup!