Introduction
This is the third part of my series explaining Pulumi using C#. In the first two posts, we introduced the basics and demonstrated how to deploy an SQL Database in Azure.
This time, I will showcase how to set up a Container Registry, publish a provisioned Keycloak container, and run the container in an Azure Container App. As a result, we’ll have a publicly accessible Keycloak instance running, which can be used to authenticate and authorize users.
There are three main components to set up:
- Azure Container Registry
- Keycloak Container
- Azure Container App
Extend Stack
This post builds upon the previously created stack, adding the required resources from the bottom up.
Add Azure Container Registry
To create an Azure Container Registry, we need to specify the resource. For this demonstration, I chose the Basic
tier, which provides a public repository. For a production-ready deployment, you should configure a more secure setup.
💡 This includes enabling zone redundancy, encryption, notary, and avoiding the use of the admin user.
1public AzureNative.ContainerRegistry.Registry AddContainerRegistry(string name)
2{
3 var registry = new AzureNative.ContainerRegistry.Registry($"registry-{name}", new()
4 {
5 RegistryName = _resourceGroup.Location.Apply(x => $"cr{name.Replace("-", "")}{x}1"),
6 ResourceGroupName = _resourceGroup.Name,
7 Location = _resourceGroup.Location,
8 AdminUserEnabled = true,
9 DataEndpointEnabled = false,
10 Encryption = new AzureNative.ContainerRegistry.Inputs.EncryptionPropertyArgs
11 {
12 // we won't encrypt this registry because it's a demo and I want to cut costs
13 Status = AzureNative.ContainerRegistry.EncryptionStatus.Disabled,
14 },
15 // Azure Services are allowed to connect to this registry
16 NetworkRuleBypassOptions = AzureNative.ContainerRegistry.NetworkRuleBypassOptions.AzureServices,
17 Policies = new AzureNative.ContainerRegistry.Inputs.PoliciesArgs
18 {
19 ExportPolicy = new AzureNative.ContainerRegistry.Inputs.ExportPolicyArgs
20 {
21 // we want to be able to export
22 Status = AzureNative.ContainerRegistry.ExportPolicyStatus.Enabled,
23 },
24 QuarantinePolicy = new AzureNative.ContainerRegistry.Inputs.QuarantinePolicyArgs
25 {
26 // we don't want to have a quarantain policy
27 Status = AzureNative.ContainerRegistry.PolicyStatus.Disabled,
28 },
29 RetentionPolicy = new AzureNative.ContainerRegistry.Inputs.RetentionPolicyArgs
30 {
31 Days = 7,
32 Status = AzureNative.ContainerRegistry.PolicyStatus.Disabled,
33 },
34 TrustPolicy = new AzureNative.ContainerRegistry.Inputs.TrustPolicyArgs
35 {
36 // we don't have trust issues for this demo
37 Status = AzureNative.ContainerRegistry.PolicyStatus.Disabled,
38 Type = AzureNative.ContainerRegistry.TrustPolicyType.Notary,
39 },
40 },
41 Sku = new AzureNative.ContainerRegistry.Inputs.SkuArgs
42 {
43 // as it's a demo i want to cut costs
44 Name = AzureNative.ContainerRegistry.SkuName.Basic,
45 },
46 // public access, due to basic tier
47 PublicNetworkAccess = AzureNative.ContainerRegistry.PublicNetworkAccess.Enabled,
48 // we don't require zone redundancy for this demo
49 ZoneRedundancy = AzureNative.ContainerRegistry.ZoneRedundancy.Disabled,
50 }, new()
51 {
52 AdditionalSecretOutputs = { "Passwords" }
53 });
54
55 // retrieve the automatically generated credentials from Azure
56 var credentials = AzureNative.ContainerRegistry.ListRegistryCredentials.Invoke(new()
57 {
58 ResourceGroupName = _resourceGroup.Name,
59 RegistryName = registry.Name,
60 });
61
62 ContainerRegistryUser = credentials.Apply(result => result.Username!);
63 ContainerRegistryPassword = credentials.Apply(result => result.Passwords[0]!.Value!).Apply(Output.CreateSecret);
64
65 return registry;
66}
Finally, I retrieve the automatically generated credentials and set them as the outputs ContainerRegistryUser
and ContainerRegistryPasssword
. These credentials will be required later to push the customized container image.
Add Keycloak Container
The next step is to prepare a Keycloak image. By default, Keycloak uses an in-memory database, which is not suitable for my scenario. They offer various options, such as Microsoft SQL Server, PostgreSQL, and others.
ℹ️ The machine running
pulumi up
must have Docker installed to build and push the image.
To use MSSQL, the container must be customized. In my Dockerfile
, I use a two-stage build to pre-configure the image by setting KC_DB
to mssql
and installing the JDBC driver with RUN /opt/keycloak/bin/kc.sh build
.
1FROM quay.io/keycloak/keycloak:25.0.1 as builder
2
3# Enable health and metrics support
4ENV KC_HEALTH_ENABLED=true
5ENV KC_METRICS_ENABLED=true
6
7# Configure a database vendor
8ENV KC_DB=mssql
9
10WORKDIR /opt/keycloak
11RUN /opt/keycloak/bin/kc.sh build
12
13FROM quay.io/keycloak/keycloak:latest
14COPY --from=builder /opt/keycloak/ /opt/keycloak/
15
16# change these values to point to a running mssql instance
17ENV KC_DB=mssql
18ENTRYPOINT ["/opt/keycloak/bin/kc.sh"]
Additionally, the health and metrics endpoints are enabled. The health endpoint is particularly relevant because the Azure Container App service will use it to determine if the container is up and healthy.
The Pulumi.Docker
package must also be added.
1dotnet add package Pulumi.Docker
To make the container version configurable, I added the key az-keycloak:keycloak.image.version: 25.0.2
to Pulumi.yaml
. This allows you to specify a different version later, rebuild and push the container, and update the Container App to use the new image.
In the method that creates the container, the version is fetched from the stack. If it doesn’t exist, the latest
version is used. The image is linked to the previously deployed registry using the automatically generated user and password. The Dockerfile
is specified, and the platform is set to `linux/amd64?. The container build process is also dependent on the registry being fully deployed.
1public Pulumi.Docker.Image AddKeycloakImage()
2{
3 var keycloakVersion = _config.Get("keycloak.image.version") ?? "latest";
4
5 return new Pulumi.Docker.Image("image-keycloak", new()
6 {
7 ImageName = _containerRegistry.LoginServer.Apply(x => $"{x}/keycloak:{keycloakVersion}"),
8 SkipPush = false,
9 Registry = new Pulumi.Docker.Inputs.RegistryArgs
10 {
11 Server = _containerRegistry.LoginServer,
12 Username = ContainerRegistryUser,
13 Password = ContainerRegistryPassword
14 },
15 Build = new Pulumi.Docker.Inputs.DockerBuildArgs
16 {
17 Context = ".",
18 Dockerfile = "Containers/Keycloak/Dockerfile",
19 Platform = "linux/amd64",
20 }
21 }, new Pulumi.CustomResourceOptions
22 {
23 DependsOn = { _containerRegistry }
24 });
25}
Add Azure Container App
Now we need a service to run our Keycloak image. To keep it simple, I chose the Azure Container App service as it is less complex than hosting on Azure Kubernetes Service.
First, we generate a random password for the admin account and set it as a new output. The password has sane complexity settings.
1// will hold the keycloak admin password
2[Output] public Output<string> KeycloakAdminPassword { get; set; } = default!;
💡 Tip
Retrieve the values after deployment with
pulumi stack output --show-secrets
The admin username is also configurable, with a fallback to kcadm
if no value is specified.
1az-keycloak:keycloak.admin.usr: "kcadm"
1var keycloakAdminUser = _config.Get("keycloak.admin.usr") ?? "kcadm";
2
3KeycloakAdminPassword = new Random.RandomPassword("password-keycloak-admin", new()
4{
5 Length = 18,
6 Special = true,
7 OverrideSpecial = "!#$%&*()-_=+[]{}<>:?",
8}).Result;
The container service requires an analytics workspace and managed environment resources. The analytics workspace collects logs produced by the Keycloak container, allowing you to inspect them live or later. The managed environment is linked to the analytics workspace using a customer identifier and shared key.
1var analyticsWorkspace = new AzureNative.OperationalInsights.Workspace($"analytics-workspace-{name}", new()
2{
3 WorkspaceName = $"analyticsworkspace-{name}",
4 ResourceGroupName = _resourceGroup.Name,
5 Location = _resourceGroup.Location,
6 PublicNetworkAccessForIngestion = AzureNative.OperationalInsights.PublicNetworkAccessType.Enabled,
7 PublicNetworkAccessForQuery = AzureNative.OperationalInsights.PublicNetworkAccessType.Enabled,
8 RetentionInDays = 30,
9 Features = new AzureNative.OperationalInsights.Inputs.WorkspaceFeaturesArgs
10 {
11 EnableLogAccessUsingOnlyResourcePermissions = true,
12 },
13 Sku = new AzureNative.OperationalInsights.Inputs.WorkspaceSkuArgs
14 {
15 Name = AzureNative.OperationalInsights.WorkspaceSkuNameEnum.PerGB2018,
16 },
17 WorkspaceCapping = new AzureNative.OperationalInsights.Inputs.WorkspaceCappingArgs
18 {
19 DailyQuotaGb = -1,
20 }
21});
22
23var workspaceSharedKeys = Output.Tuple(_resourceGroup.Name, analyticsWorkspace.Name).Apply(items =>
24 AzureNative.OperationalInsights.GetSharedKeys.Invoke(
25 new AzureNative.OperationalInsights.GetSharedKeysInvokeArgs
26 {
27 ResourceGroupName = items.Item1,
28 WorkspaceName = items.Item2
29 }
30));
ℹ️ For demonstration purposes, public access for ingestion and queries is allowed. This should not be done in production.
The managed environment get’s linked to the analytics workspace by using the customer identfier and shared key. Therefore there is the need to wait util the workspace is fully deployed.
1var managedEnvironment = new AzureNative.App.ManagedEnvironment($"managed-environment-{name}", new()
2{
3 EnvironmentName = $"managed-environment-{name}",
4 Location = _resourceGroup.Location,
5 ResourceGroupName = _resourceGroup.Name,
6 ZoneRedundant = false,
7 AppLogsConfiguration = new AzureNative.App.Inputs.AppLogsConfigurationArgs
8 {
9 Destination = "log-analytics",
10 LogAnalyticsConfiguration = new AzureNative.App.Inputs.LogAnalyticsConfigurationArgs
11 {
12 CustomerId = analyticsWorkspace.CustomerId,
13 SharedKey = workspaceSharedKeys.Apply(result => result.PrimarySharedKey ?? string.Empty)
14 }
15 }
16}, new CustomResourceOptions
17{
18 DependsOn = { analyticsWorkspace }
19});
Finally, we deploy the container app linked to the managed environment. To make Keycloak accessible, ingress rules are configured. Note that this setup uses an unencrypted connection between the app service and the container. The connection between the client and the app service is encrypted with TLS.
The container listens on port 8080
, so the ingress rules are set accordingly.
1Ingress = new AzureNative.App.Inputs.IngressArgs
2{
3 AllowInsecure = true,
4 ExposedPort = 0,
5 External = true,
6 // disable as we will have only on container instance running, add that if you have multiple
7 // otherwise you will run into issues!
8 // StickySessions = new AzureNative.App.V20240301.Inputs.IngressStickySessionsArgs
9 // {
10 // Affinity = "sticky"
11 // },
12 TargetPort = 8080,
13 Traffic = new[]
14 {
15 new AzureNative.App.Inputs.TrafficWeightArgs
16 {
17 LatestRevision = true,
18 Weight = 100,
19 },
20 },
21 Transport = "Auto",
22},
The registry where the image is stored is also specified.
1Registries = new[]
2{
3 new AzureNative.App.Inputs.RegistryCredentialsArgs // TODO: add key vault!
4 {
5 Server = _containerRegistry.LoginServer,
6 Username = _containerRegistry.Name,
7 PasswordSecretRef = "registry-password",
8 },
9},
As I decided to not use Azure KeyVault be still want to have basic security, all sensitive information is stored as secret values on the App Service.
To ensure basic security without using Azure KeyVault, all sensitive information is stored as secret values in the app service:
registry-password
keycloak-admin-password
keycloak-admin
- MSSQL database connection credentials (
kc-db-usrname
,kc-db-password
,kc-db-url
)
💡 Using Azure KeyVault for proper access management allows for password rotation and other advanced features.
1Secrets = new[]
2{
3 new AzureNative.App.Inputs.SecretArgs
4 {
5 Name = "registry-password",
6 Value = ContainerRegistryPassword
7 },
8 new AzureNative.App.Inputs.SecretArgs
9 {
10 Name = "keycloak-admin",
11 Value = keycloakAdminUser
12 },
13 new AzureNative.App.Inputs.SecretArgs
14 {
15 Name = "keycloak-admin-password",
16 Value = KeycloakAdminPassword
17 },
18 new AzureNative.App.Inputs.SecretArgs
19 {
20 Name = "kc-db-username",
21 Value = _sqlKeycloakUser
22 },
23 new AzureNative.App.Inputs.SecretArgs
24 {
25 Name = "kc-db-password",
26 Value = KeycloakDbPassword
27 },
28 new AzureNative.App.Inputs.SecretArgs
29 {
30 Name = "kc-db-url",
31 // TODO: retrieve the database name dynamically!
32 Value = _sqlServer.Name.Apply(x => $"jdbc:sqlserver://{x}.database.windows.net:1433;database=keycloak;trustServerCertificate=true;encrypt=true;trustServerCertificate=true;hostNameInCertificate=*.database.windows.net;loginTimeout=30;"),
33 }
34},
Keycloak’s container args are configured to account for running behind a reverse proxy. This ensures it expects proxy headers and allows HTTP connections.
1Containers = new[]
2{
3 new AzureNative.App.Inputs.ContainerArgs
4 {
5 Command = new[]
6 {
7 "/opt/keycloak/bin/kc.sh",
8 "start",
9 "--optimized", // we have a pre-build image, so we can directly start
10 "--hostname-strict=false", // we don't now the dynamically generated hostname at this point
11 },
12 Env = new[]
13 {
14 new AzureNative.App.Inputs.EnvironmentVarArgs
15 {
16 Name = "KC_HEALTH_ENABLED",
17 Value = "true",
18 },
19 new AzureNative.App.Inputs.EnvironmentVarArgs
20 {
21 Name = "KC_METRICS_ENABLED",
22 Value = "true",
23 },
24 new AzureNative.App.Inputs.EnvironmentVarArgs
25 {
26 Name = "KC_PROXY_HEADERS",
27 Value = "xforwarded",
28 },
29 new AzureNative.App.Inputs.EnvironmentVarArgs
30 {
31 Name = "KC_HTTP_ENABLED",
32 Value = "true",
33 },
34 new AzureNative.App.Inputs.EnvironmentVarArgs
35 {
36 Name = "KEYCLOAK_ADMIN",
37 SecretRef = "keycloak-admin"
38 },
39 new AzureNative.App.Inputs.EnvironmentVarArgs
40 {
41 Name = "KEYCLOAK_ADMIN_PASSWORD",
42 SecretRef = "keycloak-admin-password",
43 },
44 new AzureNative.App.Inputs.EnvironmentVarArgs
45 {
46 Name = "KC_DB_URL",
47 SecretRef = "kc-db-url"
48 },
49 new AzureNative.App.Inputs.EnvironmentVarArgs
50 {
51 Name = "KC_DB_USERNAME",
52 SecretRef = "kc-db-username"
53 },
54 new AzureNative.App.Inputs.EnvironmentVarArgs
55 {
56 Name = "KC_DB_PASSWORD",
57 SecretRef = "kc-db-password"
58 }
59 },
60 Image = _keyCloakImage.ImageName.Apply(x => $"{x}"), // registry.LoginServer.Apply(x => $"{x}/keycloak/:{version}")
61 Name = "keycloak",
62 Resources = new AzureNative.App.Inputs.ContainerResourcesArgs
63 {
64 // again low resource, for production you might want to have a better performance
65 Cpu = 0.25,
66 Memory = "0.5Gi",
67 },
68 },
69},
70RevisionSuffix = "",
71Scale = new AzureNative.App.Inputs.ScaleArgs
72{
73 MinReplicas = 0,
74 MaxReplicas = 0
75}
76},
Once everything is ready, deploy the infrastructure with pulumi up
.
After deployment, navigate to the Azure portal, grab the URL, and open it in your browser. Use pulumi stack output --show-secrets
to retrieve the Keycloak admin account password and log in. Enjoy configuring Keycloak! I might write another post about this.
Conclusion
I’ve demonstrated how to set up an Azure Container Registry, create and push a customized Keycloak container image, and run it in an Azure Container App. While this setup is not production-ready, that was never the goal of this demonstration. I hope you found this post valuable and that it helps you get started with Pulumi, Keycloak, Azure, or all of them.
You can find the full working code repo here.
If you have any comments, feel free to participate in the discussion.