Skip to content

Instantly share code, notes, and snippets.

@Gregory-Ledray
Last active October 13, 2025 18:10
Show Gist options
  • Save Gregory-Ledray/b1dd6a8958c6ed224aed4838450922fb to your computer and use it in GitHub Desktop.
Save Gregory-Ledray/b1dd6a8958c6ed224aed4838450922fb to your computer and use it in GitHub Desktop.
Keycloak 26.4.0 on AWS with Fargate and Aurora Postgres

README

This gist is for reproducing keycloak/keycloak#43194

Deploy 26.4.0

  1. Perform steps in Build if you need to build the Dockerfile (should not be necessary)
  2. If not done already, Authenticate. aws sso login
  3. Run:
aws cloudformation deploy --template-file ./keycloak-only.yml --stack-name KeycloakOnly --capabilities CAPABILITY_NAMED_IAM --parameter-overrides ImageUrl=public.ecr.aws/d4y1q9n5/keycloak-only-intelligentrxcom:260400

Deploy Other Versions

Replace 260400 with the appropriate version, like 260305 or 260205 or 260203

Build All Versions

aws ecr-public get-login-password --region us-east-1 | docker login --username AWS --password-stdin public.ecr.aws/d4y1q9n5 && docker build -t public.ecr.aws/d4y1q9n5/keycloak-only-intelligentrxcom:260400 -f keycloak-only.dockerfile --build-arg KEYCLOAK_VERSION="26.4.0" . && docker push public.ecr.aws/d4y1q9n5/keycloak-only-intelligentrxcom:260400

Log for creating Keycloak-Only

  • Removed hosted zones & public certificates & moved from port 443 & HTTPS on load balancer to HTTP. This makes it easier to deploy. Removed Route53
  • aws ecr-public create-repository --repository-name keycloak-only-intelligentrxcom --region us-east-1 and put output URL as default value for ImageUrl parameter
  • I expect this to be non-accessible because keycloak-only.intelligentrx.com isn't going to be mapped to the load balancer. That is OK because the service is supposed to fail on startup & I do not anticipate any traffic between Keycloak instances going over the public internet / needing DNS resolution as a result
  • Removed KMS keys whenever possible
# Purpose: For testing on localhost, especially in development of: fargateAndECSKeycloak.sh
#
# Compose:
# docker-compose -f infra/KeycloakOnly/docker-compose.yml up
#
services:
postgres:
image: postgres
# restart: always
# Limits are to check for running out of memory
deploy:
resources:
limits:
cpus: '1'
memory: 2G
volumes:
- /var/lib/postgres-keycloak-only:/var/lib/postgres-keycloak-only/data
environment:
POSTGRES_DB: "postgres"
POSTGRES_USER: "postgres"
POSTGRES_PASSWORD: "tgawnaesrv"
# POSTGRES_DB: keycloak
# POSTGRES_USER: keycloakuser
# POSTGRES_PASSWORD: keycloakpassword
PGDATA: "/var/lib/postgres-keycloak-only/data"
# Healthcheck info: https://github.com/peter-evans/docker-compose-healthcheck
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 5s
timeout: 5s
retries: 5
start_period: 3s
ports:
- "5432:5432"
# for debugging statements
command: ["postgres", "-c", "log_statement=all"]
keycloak:
image: public.ecr.aws/d4y1q9n5/keycloak-only-intelligentrxcom:260400
depends_on:
postgres:
condition: service_healthy
build:
context: .
dockerfile: ./keycloak-only.dockerfile
ports:
# 7800 and 57800 are used for cross-container communication
- "7800:7800"
# - "57800:57800"
- "8443:8443"
- "9000:9000" # health check was moved here in version 25
environment:
KC_DB_URL_DATABASE: "postgres"
KC_DB_USERNAME: "postgres"
KC_DB_PASSWORD: "tgawnaesrv"
KC_DB_URL_HOST: "host.docker.internal"
volumes:
postgres_data:
driver: local
#!/bin/sh
# newest Keycloak images don't have jq installed
# based on https://github.com/keycloak/keycloak/discussions/23400 but updated for
# Fargate platform version 1.4.0 and removed some customization done by that person
PORTNUMBER=7800
# Fargate doesn't have access to ECS_CONTAINER_METADATA_FILE so we need to get the file from $ECS_CONTAINER_METADATA_URI_V4
ECS_CONTAINER_METADATA_FILE_FROM_URI=$(curl -s "$ECS_CONTAINER_METADATA_URI_V4")
IPADDRESS=$(echo $ECS_CONTAINER_METADATA_FILE_FROM_URI | /usr/bin/jq -r '.Networks[0].IPv4Addresses[0]')
echo $ECS_CONTAINER_METADATA_FILE_FROM_URI
echo $IPADDRESS
echo $PORTNUMBER
/bin/bash /opt/keycloak/bin/kc.sh start --optimized --log-level=DEBUG --cache-embedded-network-external-address=$IPADDRESS --cache-embedded-network-external-port=$PORTNUMBER
ARG KEYCLOAK_VERSION=26.4.0
FROM registry.access.redhat.com/ubi9 AS ubi-micro-build
ARG KEYCLOAK_VERSION=26.4.0
ARG KC_HOSTNAME=keycloak-only.intelligentrx.com
ARG KEYCLOAK_DIST=https://github.com/keycloak/keycloak/releases/download/${KEYCLOAK_VERSION}/keycloak-${KEYCLOAK_VERSION}.tar.gz
RUN dnf install -y tar gzip java-17-openjdk-devel maven-openjdk17 jq
ADD $KEYCLOAK_DIST /tmp/keycloak/
RUN (cd /tmp/keycloak && \
tar -xvf /tmp/keycloak/keycloak-*.tar.gz && \
rm /tmp/keycloak/keycloak-*.tar.gz) || true
RUN mv /tmp/keycloak/keycloak-* /opt/keycloak && mkdir -p /opt/keycloak/data
COPY fargateAndECSKeycloak.sh /opt/keycloak/bin/fargateAndECSKeycloak.sh
RUN chmod -R g+rwX /opt/keycloak
COPY ubi-null.sh /tmp/
RUN bash /tmp/ubi-null.sh java-17-openjdk-headless glibc-langpack-en
WORKDIR /
FROM registry.access.redhat.com/ubi9-micro AS keycloak-image
ENV LANG=en_US.UTF-8
COPY --from=ubi-micro-build /tmp/null/rootfs/ /
COPY --from=ubi-micro-build --chown=1000:0 /opt/keycloak /opt/keycloak
RUN echo "keycloak:x:0:root" >> /etc/group && \
echo "keycloak:x:1000:0:keycloak user:/opt/keycloak:/sbin/nologin" >> /etc/passwd
USER 1000
EXPOSE 7800
EXPOSE 57800
EXPOSE 8080
EXPOSE 8443
EXPOSE 9000
ENTRYPOINT [ "/opt/keycloak/bin/kc.sh" ]
FROM keycloak-image AS builder
# Configure build options
ENV KC_DB=postgres
# feature (explanation for enabling or disabling). Documents for features: https://www.keycloak.org/server/features
# admin2 (must be able to view a console so I can edit users in an emergency), admin-api (needed by admin2), web-authn (for passwordless login), admin-api (), account-api and account3 because of
ENV KC_FEATURES=admin:v2,admin-api,web-authn,account-api,account:v3,hostname:v2,transient-users
# admin-fine-grained-authz (experimental -> risky), admin (deprecated), docker (don't need), impersonation (makes auditing too hard), scripts (don't need), token-exchange (don't need), ciba (?), par (don't need?),dynamic-scopes (don't need?), client-secret-rotation (don't need?), step-up-authentication (don't need?), recovery-codes (don't need?), update-email (don't need?), js-adapter (don't need?), device-flow (apple tv apps, don't need), dpop (preview; OAuth 2.0 Demonstrating Proof-of-Possession at the Application Layer), fips (FIPS 140-2 mode), login2 (??), multi-site (multi-site support), transient-users (if using external identity provider, this lets you not create the user in Keycloak), client-types (https://github.com/keycloak/keycloak/discussions/8497), kerberos (an auth method; don't need), oid4vc-vci (don't need), declarative-ui (experimental)
ENV KC_FEATURES_DISABLED=admin-fine-grained-authz,docker,impersonation,scripts,token-exchange,ciba,par,dynamic-scopes,client-secret-rotation,step-up-authentication,recovery-codes,update-email,authorization,client-policies,device-flow,dpop,fips,multi-site,client-types,kerberos,oid4vc-vci,declarative-ui
# deprecated linkedin-oauth (don't need), account2, offline-session-preloading
# preview is for all preview features
ENV KC_HEALTH_ENABLED=true
ENV KC_METRICS_ENABLED=true
WORKDIR /opt/keycloak
# Don't need to use a proper certificate because only ALB can access this ECS instance
RUN keytool -genkeypair -storepass password -storetype PKCS12 -keyalg RSA -keysize 2048 -dname "CN=server" -alias server -ext "SAN:c=DNS:localhost,IP:127.0.0.1" -keystore conf/server.keystore
RUN /opt/keycloak/bin/kc.sh build
FROM quay.io/keycloak/keycloak:${KEYCLOAK_VERSION} AS f
COPY --from=builder /opt/keycloak/ /opt/keycloak/
ENV KC_DB_SCHEMA=public
ENV KC_DB_URL_DATABASE=postgres
# WARN [org.keycloak.quarkus.runtime.cli.Picocli] (main) The following build time non-cli options were found, but will be ignored during run time: kc.db, kc.features, kc.features-disabled, kc.health-enabled, kc.metrics-enabled
# So I REMOVED ENV KC_FEATURES and KC_FEATURES_DISABLED
# Hostname settings
ENV KC_HOSTNAME=keycloak-only.intelligentrx.com
# ENV KC_HOSTNAME_ADMIN=
# KC_HOSTNAME_STRICT=false and KC_HOSTNAME_STRICT_BACKCHANNEL=false because the certificate is attached to the ALB, not this instance.
# It does use HTTPS between ALB and Keycloak, but the certificate is made up
ENV KC_HOSTNAME_STRICT=false
ENV KC_HOSTNAME_BACKCHANNEL_DYNAMIC=false
# HTTP/TLS settings
ENV KC_HTTP_ENABLED=false
ENV KC_HTTP_PORT=80
ENV KC_HTTPS_PROTOCOLS=TLSv1.3,TLSv1.2
# WARN [org.keycloak.quarkus.runtime.cli.Picocli] (main) The following build time non-cli options were found, but will be ignored during run time: kc.db, kc.features, kc.features-disabled, kc.health-enabled, kc.metrics-enabled
ENV KC_HEALTH_ENABLED=true
# https://www.keycloak.org/server/reverseproxy
# We encrypt to load balancer, and then terminate, and then reencrypt to Keycloak
# The suggested migration is KC_PROXY_HEADERS=forwarded|xforwarded
# https://www.keycloak.org/docs/latest/upgrading/index.html#deprecated-proxy-option
ENV KC_PROXY_HEADERS=forwarded
# Logging Configuration
ENV KC_LOG=console
ENV KC_LOG_CONSOLE_COLOR=false
ENV KC_LOG_CONSOLE_OUTPUT=default
ENV KC_LOG_LEVEL=DEBUG
ENV KEYCLOAK_ADMIN=admin
ENV KEYCLOAK_ADMIN_PASSWORD=password
# works:
# HEALTHCHECK --interval=60s --timeout=5s --start-period=30s --retries=3 CMD curl --fail --insecure https://localhost:9000 || exit 1
# causes health check to fail:
# HEALTHCHECK --interval=60s --timeout=5s --start-period=30s --retries=3 CMD { printf 'HEAD /health HTTP/1.0\r\n\r\n' >&0; grep 'HTTP/1.0 200'; } 0<>/dev/tcp/localhost/9000 || exit 1
# in testing this worked on localhost:
ENV KC_HTTP_MANAGEMENT_SCHEME=http
HEALTHCHECK --interval=60s --timeout=5s --start-period=30s --retries=3 CMD { printf 'HEAD /health HTTP/1.0\r\n\r\n' >&0; grep 'HTTP/1.0 200'; } 0<>/dev/tcp/localhost/9000 || exit 1
EXPOSE 7800
EXPOSE 57800
EXPOSE 8443
EXPOSE 9000
# jq is used in fargateandECSKeycloak.sh but isn't part of this image
# Copy over both the bin and its libs - this is janky but I don't see an easier way with no package manager installed in this image
COPY --from=ubi-micro-build /usr/bin/jq /usr/bin/jq
COPY --from=ubi-micro-build /usr/lib64/libm.so.6 /usr/lib64/libm.so.6
COPY --from=ubi-micro-build /usr/lib64/libonig.so.5 /usr/lib64/libonig.so.5
COPY --from=ubi-micro-build /usr/lib64/libc.so.6 /usr/lib64/libc.so.6
COPY --from=ubi-micro-build /usr/lib64/libpthread.so.0 /usr/lib64/libpthread.so.0
COPY --from=ubi-micro-build /usr/lib64/libjq.so.1 /usr/lib64/libjq.so.1
# Also need Curl because Fargate doesn't store metadata in environment variables, but instead you have to GET them
COPY --from=ubi-micro-build /usr/bin/curl /usr/bin/curl
COPY --from=ubi-micro-build /usr/lib64/libcurl.so.4 /usr/lib64/libcurl.so.4
COPY --from=ubi-micro-build /usr/lib64/libnghttp2.so.14 /usr/lib64/libnghttp2.so.14
COPY --from=ubi-micro-build /usr/lib64/libssl.so.3 /usr/lib64/libssl.so.3
COPY --from=ubi-micro-build /usr/lib64/libcrypto.so.3 /usr/lib64/libcrypto.so.3
COPY --from=ubi-micro-build /usr/lib64/libgssapi_krb5.so.2 /usr/lib64/libgssapi_krb5.so.2
COPY --from=ubi-micro-build /usr/lib64/libkrb5.so.3 /usr/lib64/libkrb5.so.3
COPY --from=ubi-micro-build /usr/lib64/libk5crypto.so.3 /usr/lib64/libk5crypto.so.3
COPY --from=ubi-micro-build /usr/lib64/libcom_err.so.2 /usr/lib64/libcom_err.so.2
COPY --from=ubi-micro-build /usr/lib64/libkrb5support.so.0 /usr/lib64/libkrb5support.so.0
COPY --from=ubi-micro-build /usr/lib64/libkeyutils.so.1 /usr/lib64/libkeyutils.so.1
# Create non-elevated user
USER 0
RUN echo "nonpriv:x:10001" >> /etc/group
RUN echo "nonprivuser:x:10001:10001:nonprivuser user:/opt/keycloak:/sbin/nologin" >> /etc/passwd
RUN chown -cR nonprivuser:nonpriv /opt/keycloak
RUN chown -cR nonprivuser:nonpriv /usr/bin/jq
USER nonprivuser
ENTRYPOINT ["/opt/keycloak/bin/fargateAndECSKeycloak.sh"]
AWSTemplateFormatVersion: '2010-09-09'
Description: AWS Fargate template
Parameters:
ImageUrl:
Type: String
Default: public.ecr.aws/d4y1q9n5/keycloak-only-intelligentrxcom:260400
KCHOSTNAME:
Type: String
Default: keycloak-only.intelligentrx.com
KCDBUSERNAME:
Type: String
Default: KeycloakUsername
KCDBPASSWORD:
Type: String
Default: KeycloakPassword
NoEcho: true
Environment:
Type: String
Default: dev
Region:
Type: String
Default: us-east-1
DatabaseName:
Type: String
Default: KeycloakOnlyDatabase
Mappings:
# Hard values for the subnet masks. These masks define
# the range of internal IP addresses that can be assigned.
# The VPC can have all IP's from 10.0.0.0 to 10.0.255.255
# There are four subnets which cover the ranges:
#
# 10.0.0.0 - 10.0.63.255 (16384 IP addresses)
# 10.0.64.0 - 10.0.127.255 (16384 IP addresses)
# 10.0.128.0 - 10.0.191.255 (16384 IP addresses)
# 10.0.192.0 - 10.0.255.0 (16384 IP addresses)
SubnetConfig:
VPC:
CIDR: '10.30.0.0/16'
PublicOne:
CIDR: '10.30.0.0/18'
PublicTwo:
CIDR: '10.30.64.0/18'
PrivateOne:
CIDR: '10.30.128.0/18'
PrivateTwo:
CIDR: '10.30.192.0/18'
PrivateBoth:
CIDR: '10.30.128.0/17'
Resources:
VPC:
Type: AWS::EC2::VPC
Properties:
EnableDnsSupport: true
EnableDnsHostnames: true
CidrBlock: !FindInMap ['SubnetConfig', 'VPC', 'CIDR']
# Two public subnets, where containers can have public IP addresses
PublicSubnetOne:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone:
Fn::Select:
- 0
- Fn::GetAZs: {Ref: 'AWS::Region'}
VpcId: !Ref 'VPC'
CidrBlock: !FindInMap ['SubnetConfig', 'PublicOne', 'CIDR']
MapPublicIpOnLaunch: true
PublicSubnetTwo:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone:
Fn::Select:
- 1
- Fn::GetAZs: {Ref: 'AWS::Region'}
VpcId: !Ref 'VPC'
CidrBlock: !FindInMap ['SubnetConfig', 'PublicTwo', 'CIDR']
MapPublicIpOnLaunch: true
# Two private subnets where containers will only have private
# IP addresses, and will only be reachable by other members of the
# VPC
PrivateSubnetOne:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone:
Fn::Select:
- 0
- Fn::GetAZs: {Ref: 'AWS::Region'}
VpcId: !Ref 'VPC'
CidrBlock: !FindInMap ['SubnetConfig', 'PrivateOne', 'CIDR']
PrivateSubnetTwo:
Type: AWS::EC2::Subnet
Properties:
AvailabilityZone:
Fn::Select:
- 1
- Fn::GetAZs: {Ref: 'AWS::Region'}
VpcId: !Ref 'VPC'
CidrBlock: !FindInMap ['SubnetConfig', 'PrivateTwo', 'CIDR']
# Setup networking resources for the public subnets. Containers
# in the public subnets have public IP addresses and the routing table
# sends network traffic via the internet gateway.
InternetGateway:
Type: AWS::EC2::InternetGateway
GatewayAttachement:
Type: AWS::EC2::VPCGatewayAttachment
Properties:
VpcId: !Ref 'VPC'
InternetGatewayId: !Ref 'InternetGateway'
PublicRouteTable:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref 'VPC'
PublicRoute:
Type: AWS::EC2::Route
DependsOn: GatewayAttachement
Properties:
RouteTableId: !Ref 'PublicRouteTable'
DestinationCidrBlock: '0.0.0.0/0'
GatewayId: !Ref 'InternetGateway'
PublicSubnetOneRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnetOne
RouteTableId: !Ref PublicRouteTable
PublicSubnetTwoRouteTableAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
SubnetId: !Ref PublicSubnetTwo
RouteTableId: !Ref PublicRouteTable
# Setup networking resources for the private subnets. Containers
# in these subnets have only private IP addresses, and must use a NAT
# gateway to talk to the internet. We launch two NAT gateways, one for
# each private subnet.
NATGatewayEIP:
Type: AWS::EC2::EIP
Properties:
Domain: vpc
NatGatewayOne:
Type: AWS::EC2::NatGateway
Properties:
AllocationId: !GetAtt NATGatewayEIP.AllocationId
SubnetId: !Ref PublicSubnetOne
PrivateRouteTableOne:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref 'VPC'
PrivateRouteOne:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref PrivateRouteTableOne
DestinationCidrBlock: 0.0.0.0/0
NatGatewayId: !Ref NatGatewayOne
PrivateRouteTableOneAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PrivateRouteTableOne
SubnetId: !Ref PrivateSubnetOne
PrivateRouteTableTwo:
Type: AWS::EC2::RouteTable
Properties:
VpcId: !Ref 'VPC'
PrivateRouteTwo:
Type: AWS::EC2::Route
Properties:
RouteTableId: !Ref PrivateRouteTableTwo
DestinationCidrBlock: 0.0.0.0/0
# Note this was previously NatGatewayTwo
NatGatewayId: !Ref NatGatewayOne
PrivateRouteTableTwoAssociation:
Type: AWS::EC2::SubnetRouteTableAssociation
Properties:
RouteTableId: !Ref PrivateRouteTableTwo
SubnetId: !Ref PrivateSubnetTwo
MainDBClusterProvisioned:
Type: 'AWS::RDS::DBCluster'
DeletionPolicy: Delete # This will prevent a final snapshot for the instance
DependsOn: mainDBSubnetGroup
Properties:
MasterUsername: !Ref KCDBUSERNAME
MasterUserPassword: !Ref KCDBPASSWORD
BackupRetentionPeriod: 4
AutoMinorVersionUpgrade: true
DatabaseName: !Ref DatabaseName
DBClusterIdentifier: !Ref DatabaseName
DBClusterParameterGroupName: default.aurora-postgresql15
DBSubnetGroupName:
!Join [
"-",
[!Ref Region, main-db-subnet-group, !Ref Environment],
]
EnableCloudwatchLogsExports:
- postgresql
EnableHttpEndpoint: False
# aws rds describe-db-engine-versions --engine aurora-postgresql --query '*[].[EngineVersion]' --output text --region us-east-2
# aws rds describe-db-engine-versions --engine aurora-postgresql --query '*[].[EngineVersion]' --output text --region us-east-1
Engine: aurora-postgresql
EngineVersion: 15.8
EngineMode: provisioned
Port: 5432
SourceRegion: !Ref Region
StorageEncrypted: true
Tags:
- Key: Name
Value: !Join ["-", [!Ref Region, main, !Ref Environment]]
VpcSecurityGroupIds:
- !GetAtt MainDBSecurityGroup.GroupId
MainDBSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupName:
!Join [
"-",
[!Ref Region, main-db-security-group-2, !Ref Environment],
]
GroupDescription: Allow inbound traffic
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 5432
ToPort: 5432
CidrIp: !FindInMap ['SubnetConfig', 'PrivateBoth', 'CIDR']
SecurityGroupEgress:
- IpProtocol: tcp
FromPort: 5432
ToPort: 5432
CidrIp: !FindInMap ['SubnetConfig', 'PrivateBoth', 'CIDR']
VpcId: !Ref VPC
RDSDBInstance1:
Type: 'AWS::RDS::DBInstance'
Properties:
DBInstanceIdentifier: !Ref DatabaseName
Engine: aurora-postgresql
DBClusterIdentifier: !Ref MainDBClusterProvisioned
PubliclyAccessible: false
DBInstanceClass: db.t4g.medium
mainDBSubnetGroup:
Type: AWS::RDS::DBSubnetGroup
Properties:
DBSubnetGroupName:
!Join [
"-",
[!Ref Region, main-db-subnet-group, !Ref Environment],
]
DBSubnetGroupDescription: Used by MainDBCluster so that that DBCluster is inside the same VPC the host-dev or other service is inside
SubnetIds: !Split
- ','
- !Sub '${PrivateSubnetOne},${PrivateSubnetTwo}'
Tags:
-
Key: Name
Value: !Join ["-", [!Ref Region, main, !Ref Environment]]
KeycloakOnlyRole:
Type: AWS::IAM::Role
Properties:
RoleName: ecs-task-execution-role-KeycloakOnly
AssumeRolePolicyDocument:
Statement:
- Effect: Allow
Principal:
Service: [ecs-tasks.amazonaws.com]
Action: ["sts:AssumeRole"]
Condition:
ArnLike:
aws:SourceArn: !Sub arn:aws:ecs:${AWS::Region}:${AWS::AccountId}:*
StringEquals:
aws:SourceAccount: !Ref AWS::AccountId
Path: /
ManagedPolicyArns:
# This role enables basic features of ECS. See reference:
# https://docs.aws.amazon.com/AmazonECS/latest/developerguide/security-iam-awsmanpol.html#security-iam-awsmanpol-AmazonECSTaskExecutionRolePolicy
- arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
- arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly
# - arn:aws:iam::aws:policy/AmazonSSMFullAccess
- arn:aws:iam::aws:policy/CloudWatchLogsFullAccess
# This is the 'inline' permissions document
Policies:
- PolicyName: applicationspecific
PolicyDocument:
Version: "2012-10-17"
Statement:
- Effect: Allow
Action:
- kms:Decrypt
- ssm:GetParameterHistory
- ecr:GetDownloadUrlForLayer
- ecr:GetAuthorizationToken
- ssm:GetParameters
- logs:PutLogEvents
- ssm:GetParameter
- logs:CreateLogStream
- secretsmanager:GetSecretValue
- kms:Encrypt
- ecr:BatchGetImage
- ssm:GetParametersByPath
- ecr:BatchCheckLayerAvailability
Resource: "*"
# ECS Resources
ECSCluster:
Type: AWS::ECS::Cluster
Properties:
ClusterName: !Sub ecs-KeycloakOnly
ClusterSettings:
- Name: containerInsights
Value: enabled
TaskDefinition:
Type: AWS::ECS::TaskDefinition
Properties:
Family: KeycloakOnly
Cpu: 512
Memory: 2048
NetworkMode: awsvpc
RequiresCompatibilities:
- FARGATE
ExecutionRoleArn: !GetAtt KeycloakOnlyRole.Arn
TaskRoleArn: !GetAtt KeycloakOnlyRole.Arn
ContainerDefinitions:
- Name: KeycloakOnly
User: nonprivuser
Image: !Ref ImageUrl
HealthCheck:
Command:
- "CMD-SHELL"
- "{ printf 'HEAD /health HTTP/1.0\r\n\r\n' >&0; grep 'HTTP/1.0 200'; } 0<>/dev/tcp/localhost/9000 || exit 1"
Interval: 30
Retries: 3
StartPeriod: 30
Timeout: 3
PortMappings:
- ContainerPort: 9000
HostPort: 9000
- ContainerPort: 8443
HostPort: 8443
# 7800 and 57800 are used for cross-container communication https://www.keycloak.org/server/caching#network-ports
- ContainerPort: 7800
HostPort: 7800
- ContainerPort: 57800
HostPort: 57800
Environment:
- Name: "KC_HOSTNAME"
Value: !Ref KCHOSTNAME
- Name: "KC_DB_URL_HOST"
Value: !GetAtt MainDBClusterProvisioned.Endpoint.Address
- Name: "KC_DB_URL_PORT"
Value: 5432
- Name: "KC_DB_URL_DATABASE"
Value: postgres
- Name: "KC_DB_USERNAME"
Value: !Ref KCDBUSERNAME
- Name: "KC_DB_PASSWORD"
Value: !Ref KCDBPASSWORD
- Name: "DB_KEYCLOAK_PASSWORD"
Value: !Ref KCDBPASSWORD
LogConfiguration:
LogDriver: 'awslogs'
Options:
mode: non-blocking
# As of 2025/05/20:
# We have an extra 6+ GB of RAM and 19+ GiB of ephemeral storage in the main service to play with
# Max logs per hour is 454 MB. Max daily is 5 GB
# I have seen downtime as less than 1 hour in the past
# https://app.clickup.com/t/86b4yet7r
max-buffer-size: 4g
awslogs-group: !Ref LogGroup
awslogs-region: !Ref AWS::Region
awslogs-stream-prefix: KeycloakOnly
# Keycloak does NOT support a read-only file system
# https://github.com/keycloak/keycloak/issues/11286
ReadonlyRootFilesystem: false
# The service. The service is a resource which allows you to run multiple
# copies of a type of task, and gather up their logs and metrics, as well
# as monitor the number of running tasks and replace any that have crashed
Service:
Type: AWS::ECS::Service
# Avoid race condition between ECS service creation and associating
# the target group with the LB
DependsOn:
- PublicLoadBalancerListener
- PublicLoadBalancerListenerRule1
- RDSDBInstance1
Properties:
ServiceName: KeycloakOnly
Cluster: !Ref ECSCluster
LaunchType: FARGATE
NetworkConfiguration:
AwsvpcConfiguration:
AssignPublicIp: DISABLED
SecurityGroups:
- !Ref ServiceSecurityGroup
Subnets: !Split
- ','
- !Sub '${PrivateSubnetOne},${PrivateSubnetTwo}'
DeploymentConfiguration:
MaximumPercent: 200
MinimumHealthyPercent: 75
# This is to speed up failed deployments - and yes, it does work
DeploymentCircuitBreaker:
Enable: Yes
Rollback: Yes
HealthCheckGracePeriodSeconds: 30
DeploymentController:
Type: ECS
DesiredCount: 1
TaskDefinition: !Ref TaskDefinition
LoadBalancers:
- ContainerName: KeycloakOnly
ContainerPort: 8443
TargetGroupArn: !Ref ServiceTargetGroup
# Security group that limits network access
# to the task
ServiceSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Security group for service
VpcId: !Ref VPC
# Keeps track of the list of tasks for the service
ServiceTargetGroup:
Type: AWS::ElasticLoadBalancingV2::TargetGroup
# It can take 2+ minutes to boot up for the very first time because "InstallRxNorm" takes more than one minute
# With a 6 second * 10 timeout, there is insufficient time for a deployment to finish
# I tried to rectify this by going to 15 * 8, or allowing 2 minutes to establish a target a healthy
Properties:
HealthCheckIntervalSeconds: 30
HealthCheckPath: /health
HealthCheckPort: 9000 # moved in version 25 and (optionally) moved back in version 26.4.0
HealthCheckProtocol: HTTP
HealthCheckTimeoutSeconds: 5
HealthyThresholdCount: 2
TargetType: ip
Port: 8443
Protocol: HTTPS
UnhealthyThresholdCount: 4
VpcId: !Ref VPC
TargetGroupAttributes:
- Key: deregistration_delay.timeout_seconds
Value: 30
# Stickiness is so that if we ever DO have multiple instances it works correctly
- Key: stickiness.enabled
Value: true
- Key: stickiness.type
Value: lb_cookie
# A public facing load balancer, this is used as ingress for
# public facing internet traffic.
PublicLoadBalancerSG:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Access to the public facing load balancer
VpcId: !Ref VPC
SecurityGroupIngress:
# Allow access to public facing ALB from any IP address
- CidrIp: 0.0.0.0/0
IpProtocol: tcp
FromPort: 80
ToPort: 80
PublicLoadBalancer:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Scheme: internet-facing
LoadBalancerAttributes:
- Key: idle_timeout.timeout_seconds
Value: '30'
Subnets: !Split
- ','
- !Sub '${PublicSubnetOne},${PublicSubnetTwo}'
SecurityGroups:
- !Ref PublicLoadBalancerSG
# This is the default rule; it is the fallback
PublicLoadBalancerListener:
Type: AWS::ElasticLoadBalancingV2::Listener
Properties:
DefaultActions:
- Type: 'fixed-response'
FixedResponseConfig:
StatusCode: 404
LoadBalancerArn: !Ref 'PublicLoadBalancer'
# In my template this is 443 w/ Protocol HTTP, but to use 443 and HTTPS you need a certificate & a domain which I don't want to include in this template
Port: 80
Protocol: HTTP
# this is the main rule we want to see used
PublicLoadBalancerListenerRule1:
Type: 'AWS::ElasticLoadBalancingV2::ListenerRule'
Properties:
Actions:
- Type: forward
TargetGroupArn: !Ref ServiceTargetGroup
Conditions:
- Field: path-pattern
PathPatternConfig:
Values:
- "*"
ListenerArn: !Ref PublicLoadBalancerListener
Priority: 10
# Open up the service's security group to traffic originating
# from the security group of the load balancer.
ServiceIngressfromLoadBalancer:
Type: AWS::EC2::SecurityGroupIngress
Properties:
Description: Ingress from the public ALB
GroupId: !Ref ServiceSecurityGroup
IpProtocol: tcp
FromPort: 8443
ToPort: 8443
SourceSecurityGroupId: !Ref 'PublicLoadBalancerSG'
ServiceIngressfromLoadBalancerKeycloakHealthCheck:
Type: AWS::EC2::SecurityGroupIngress
Properties:
Description: Ingress for health check
GroupId: !Ref ServiceSecurityGroup
IpProtocol: tcp
FromPort: 9000
ToPort: 9000
SourceSecurityGroupId: !Ref 'PublicLoadBalancerSG'
ServiceIngressfromLoadBalancerKeycloak2:
Type: AWS::EC2::SecurityGroupIngress
Properties:
Description: Inter-container communication
GroupId: !Ref ServiceSecurityGroup
IpProtocol: tcp
FromPort: 7800
ToPort: 7800
SourceSecurityGroupId: !Ref 'PublicLoadBalancerSG'
ServiceIngressfromLoadBalancerKeycloak3:
Type: AWS::EC2::SecurityGroupIngress
Properties:
Description: Inter-container communication
GroupId: !Ref ServiceSecurityGroup
IpProtocol: tcp
# New security group port
FromPort: 57800
ToPort: 57800
SourceSecurityGroupId: !Ref 'PublicLoadBalancerSG'
# This log group stores the stdout logs from this service's containers
LogGroup:
Type: AWS::Logs::LogGroup
Properties:
LogGroupName: KeycloakOnly
RetentionInDays: 2557 # 7 years (HIPAA)
Outputs:
HealthCheckProtocol:
Value: HTTPS
#!/bin/bash
# tr -d '\r' < ubi-null.sh > temp.sh && mv temp.sh ubi-null.sh
set -euo pipefail
#set -x
dir="/tmp/null"
rm -rf "$dir"
mkdir "$dir"
cd "$dir"
# Add all arguments as the initial core packages
printf '%s\n' "$@" > keep
# Packages required for a shell environment
cat >>keep <<EOF
bash
coreutils-single
EOF
# Disallow list to block certain packages and their dependencies
cat >disallow <<EOF
alsa-lib
copy-jdk-configs
cups-libs
chkconfig
info
gawk
platform-python
platform-python-setuptools
python3
python3-libs
python3-pip-wheel
python3-setuptools-wheel
p11-kit
EOF
sort -u keep -o keep
echo "==> Installing packages into chroot" >&2
set -x
# Install requirements for this script (xargs and cmp)
dnf install -y findutils diffutils
# Install core packages to chroot
rootfs="$(realpath rootfs)"
mkdir -p "$rootfs"
<keep xargs dnf install -y --installroot "$rootfs" --releasever 9 --setopt install_weak_deps=false --nodocs
dnf --installroot "$rootfs" clean all
rm -rf "$rootfs"/var/cache/* "$rootfs"/var/log/dnf* "$rootfs"/var/log/yum.*
{ set +x; } 2>/dev/null
echo "==> Building dependency tree" >&2
# Loop until we have the full dependency tree (no new packages found)
touch old
while ! cmp -s keep old
do
# 1. Get requirement names (not quite the same as package names)
# 2. Filter out any install-time requirements
# 3. Query which packages are being used to satisfy the requirements
# 4. Keep just their package names
# 5. Remove packages that are on the disallow list
# 6. Store result as an allowlist
<keep xargs rpm -r "$rootfs" -q --requires | sort -Vu | cut -d ' ' -f1 \
| grep -v -e '^rpmlib(' \
| xargs -d $'\n' rpm -r "$rootfs" -q --whatprovides \
| grep -v -e '^no package provides' \
| sed -r 's/^(.*)-.*-.*$/\1/' \
| grep -vxF -f disallow \
> new || true
# Safely replace the keep list, appending the new names
mv keep old
cat old new > keep
# Sort and deduplicate so cmp will eventually return true
sort -u keep -o keep
done
# Determine all packages that need to be removed
rpm -r "$rootfs" -qa | sed -r 's/^(.*)-.*-.*$/\1/' | sort -u > all
# Set complement (all - keep)
grep -vxF -f keep all > remove
echo "==> $(wc -l remove | cut -d ' ' -f1) packages to erase:" >&2
cat remove
echo "==> $(wc -l keep | cut -d ' ' -f1) packages to keep:" >&2
cat keep
echo "" >&2
echo "==> Erasing packages" >&2
# Delete all packages that aren't needed for the core packages
set -x
<remove xargs rpm -r "$rootfs" --erase --nodeps --allmatches
{ set +x; } 2>/dev/null
echo "" >&2
echo "==> Packages erased ok!" >&2
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment