Showing posts with label container. Show all posts
Showing posts with label container. Show all posts

Adding Keycloak Authentication to an Existing .NET Aspire Application

By the end of this post, you'll have a working login/logout flow backed by Keycloak, running locally via Aspire and deployable via Docker Compose.

If your Aspire app doesn't have authentication yet, this is your fastest path to a real identity provider. This tutorial walks through wiring Keycloak OIDC into an existing .NET Aspire + Blazor Server app: from AppHost registration to login/logout UI, using production code from NoteBookmark, an open-source bookmark manager built with .NET Aspire and Blazor.\


Step 1: Add Aspire.Hosting.Keycloak to AppHost

Aspire provides first-class Keycloak support through the Aspire.Hosting.Keycloak package. Add it to your AppHost project:

For AppHost project

dotnet add package Aspire.Hosting.Keycloak

Run dotnet restore to pull the package.


Step 2: Register Keycloak in AppHost.cs

With the package installed, register Keycloak as a resource in your AppHost. Aspire will spin up a Keycloak container, wire its connection details into dependent projects, and ensure proper startup ordering.


// ...

// Add Keycloak authentication server
var keycloak = builder.AddKeycloak("keycloak", port: 8080)
    .WithDataVolume(); // Persist Keycloak data across container restarts

if (builder.Environment.IsDevelopment())
{
    // ...

    builder.AddProject<NoteBookmark_BlazorApp>("blazor-app")
        // ...
        .WithReference(keycloak)  // <-- reference Keycloak
        .WaitFor(keycloak)  // <-- wait for Keycloak to be ready
        .WithExternalHttpEndpoints()
        .PublishAsDockerComposeService((resource, service) =>
        {
            service.ContainerName = "notebookmark-blazor";
        });
}

Key Changes:

  • AddKeycloak("keycloak", port: 8080): Registers a Keycloak resource listening on port 8080.
  • WithDataVolume(): Persists Keycloak's configuration and realm data across container restarts. Without this, you'd lose your realm setup every time the container stops.
  • .WithReference(keycloak): Injects Keycloak connection settings (base URL, etc.) into the BlazorApp as environment variables.
  • .WaitFor(keycloak): Ensures Keycloak is fully started before launching the Blazor app. This is critical: if your app starts before Keycloak is ready, OIDC discovery will fail.

 

Step 3: Set Up Keycloak for Non-Aspire (aka prod) Deployments

This post focuses on the Aspire dev setup, but for production (Docker Compose, Kubernetes), you need a standalone Keycloak. Here's what that looks like and why.

Aspire can actually help you bridge the gap. AddDockerComposeEnvironment() in AppHost generates a draft Docker Compose file from your Aspire model, a great starting point before customizing for production. Worth checking out if you want a head start.

The final compose files for both Keycloak and the NoteBookmark app are available in the NoteBookmark repo:

A few things worth noting about the setup:

  • Postgres as the backing store: Keycloak uses a dedicated Postgres instance (not the app's database) to persist realm configuration, users, and sessions.
  • KC_HTTP_ENABLED: "true": Allows HTTP traffic internally. In production, Keycloak runs behind a reverse proxy (nginx, Traefik) that handles TLS termination: HTTPS externally, HTTP internally.
  • KC_FEATURES: "token-exchange": Enables the token exchange feature, needed if you want service-to-service auth flows.

Step 4: Configure Keycloak Realm and OIDC Client

This configuration is required in both production and development environments, but only needs to be done once per environment. In dev, thanks to .WithDataVolume(), all Keycloak settings are persisted between run and debug sessions, so you only configure it once and it survives restarts.

Once Keycloak is running, configure it:

  1. Navigate to http://localhost:8080 and log in with your admin credentials.
  2. Create a new realm:
    • Click Create Realm
    • Name: notebookmark (match the realm in your Authority URL below)
  3. Create an OIDC client:
    • Clients → Create Client
    • Client ID: notebookmark
    • Client Protocol: openid-connect
    • Access Type: confidential (generates a client secret)
    • Valid Redirect URIs: http://localhost:5173/* (adjust for your Blazor app's URL)
    • Web Origins: http://localhost:5173
  4. Go to the Credentials tab and copy the Client Secret: you'll need this in your app config.
Keycloak client configuration screen


Step 5: Add OpenID Connect to the Blazor App

Now wire up the authentication pipeline in your Blazor Server app.

Add the NuGet Package


For BlazorApp project

dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect

Update Program.cs

BlazorApp/Program.cs:

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.OpenIdConnect;

//...

// Add authentication
builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
    var authority = builder.Configuration["Keycloak:Authority"];
    options.Authority = authority;
    options.ClientId = builder.Configuration["Keycloak:ClientId"];
    options.ClientSecret = builder.Configuration["Keycloak:ClientSecret"];
    options.ResponseType = "code";
    options.SaveTokens = true;
    options.GetClaimsFromUserInfoEndpoint = true;

    // Allow overriding RequireHttpsMetadata via configuration.
    // Relax the requirement when running in a container against HTTP Keycloak.
    var requireHttpsConfigured = builder.Configuration.GetValue<bool?>("Keycloak:RequireHttpsMetadata");
    var isRunningInContainer = string.Equals(
        System.Environment.GetEnvironmentVariable("DOTNET_RUNNING_IN_CONTAINER"),
        "true",
        StringComparison.OrdinalIgnoreCase);

    if (requireHttpsConfigured.HasValue)
    {
        options.RequireHttpsMetadata = requireHttpsConfigured.Value;
    }
    else
    {
        var defaultRequireHttps = !builder.Environment.IsDevelopment();
        if (isRunningInContainer &&
            !string.IsNullOrEmpty(authority) &&
            authority.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
        {
            defaultRequireHttps = false;
        }
        options.RequireHttpsMetadata = defaultRequireHttps;
    }

    options.Scope.Clear();
    options.Scope.Add("openid");
    options.Scope.Add("profile");
    options.Scope.Add("email");

    options.TokenValidationParameters = new()
    {
        NameClaimType = "preferred_username",
        RoleClaimType = "roles"
    };

    // Configure logout to pass id_token_hint to Keycloak
    options.Events = new OpenIdConnectEvents
    {
        OnRedirectToIdentityProviderForSignOut = async context =>
        {
            var idToken = await context.HttpContext.GetTokenAsync("id_token");
            if (!string.IsNullOrEmpty(idToken))
            {
                context.ProtocolMessage.IdTokenHint = idToken;
            }
        }
    };
});

builder.Services.AddAuthorization();
builder.Services.AddCascadingAuthenticationState();
builder.Services.AddHttpContextAccessor();

// ... existing Razor Components, FluentUI, etc. ...

var app = builder.Build();
app.MapDefaultEndpoints();

// ... existing middleware ...

// CRITICAL: UseAuthentication BEFORE UseAuthorization
app.UseAuthentication();
app.UseAuthorization();

app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode();

// Authentication endpoints
app.MapGet("/authentication/login", async (HttpContext context, string? returnUrl) =>
{
    var authProperties = new AuthenticationProperties { RedirectUri = returnUrl ?? "/" };
    await context.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties);
});

app.MapGet("/authentication/logout", async (HttpContext context) =>
{
    var authProperties = new AuthenticationProperties { RedirectUri = "/" };
    await context.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
    await context.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties);
});

app.Run();


Configuration


Create or update appsettings.json in the BlazorApp project:

{
  "Keycloak": {
    "Authority": "http://localhost:8080/realms/notebookmark",
    "ClientId": "notebookmark",
    "ClientSecret": "your-client-secret-from-keycloak",
    "RequireHttpsMetadata": false
  }
}

For your Prod, Docker Compose deployments, use environment variables in your docker-compose.yaml:

environment:
  Keycloak__Authority: ${KEYCLOAK_AUTHORITY}
  Keycloak__ClientId: ${KEYCLOAK_CLIENT_ID}
  Keycloak__ClientSecret: ${KEYCLOAK_CLIENT_SECRET}
In the development enviroment, Aspire's .WithReference(keycloak) automatically injects environment variables like services__keycloak__http__0 for the Keycloak base URL. You can read this in your config or manually set the Authority URL as shown above.

 

Handling HTTP vs HTTPS: The RequireHttpsMetadata Gotcha

By default, the OpenID Connect middleware requires HTTPS for metadata discovery (RequireHttpsMetadata = true). This is a security best practice for production, but it causes problems in local/container dev environments where Keycloak runs on HTTP.

The code above implements a smart fallback:

  1. Check explicit configuration first: If Keycloak:RequireHttpsMetadata is set in config, use that value.
  2. Detect container environment: If running in a container (DOTNET_RUNNING_IN_CONTAINER=true) and the Authority URL is HTTP, disable the HTTPS requirement.
  3. Default to HTTPS in production: Outside of Development mode, default to requiring HTTPS.

This ensures:

  • Local dev works seamlessly with HTTP Keycloak
  • Container-to-container communication works (HTTP internally)
  • Production enforces HTTPS (assuming you've configured it properly)

Note: In production, run Keycloak behind a reverse proxy (nginx, Traefik, etc.) that handles TLS termination. Your app sees https://yourdomain.com, Keycloak internally runs on HTTP.

That's the server-side setup done. Now let's build the Blazor UI pieces that make auth visible to users.


Step 6: Blazor UI: Login, Logout, and Route Protection

With the backend authentication pipeline configured, it's time to build the UI components that let users actually log in, log out, and interact with protected content. We'll create three key pieces: the login/logout pages, a login display component, and routing configuration that enforces authorization.

Keycloak login screen

The Login and Logout Razor Pages

First, we need pages to trigger authentication flows. These aren't typical Blazor pages with markup—they're redirect triggers that hand off control to Keycloak.

Login.razor

Create Components/Pages/Login.razor:

@page "/login"
@attribute [AllowAnonymous]
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.OpenIdConnect
@inject NavigationManager Navigation
@inject IHttpContextAccessor HttpContextAccessor

@code {
    protected override async Task OnInitializedAsync()
    {
        var uri = new Uri(Navigation.Uri);
        var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
        var returnUrl = query["returnUrl"] ?? "/";

        var httpContext = HttpContextAccessor.HttpContext;
        if (httpContext != null)
        {
            var authProperties = new AuthenticationProperties
            {
                RedirectUri = returnUrl
            };
            await httpContext.ChallengeAsync(OpenIdConnectDefaults.AuthenticationScheme, authProperties);
        }
    }
}

What's happening here?

  • No markup: This page doesn't render anything. Its job is to initiate the OpenID Connect authentication challenge, which redirects the browser to Keycloak.
  • ChallengeAsync: This triggers the OIDC middleware to redirect the user to Keycloak's login page.
  • Return URL: We capture the returnUrl query parameter so users land back where they started after logging in.
  • [AllowAnonymous]: Critical! Without this, the page would require authentication to access, creating a redirect loop.

Logout.razor

Create Components/Pages/Logout.razor:

@page "/logout"
@attribute [AllowAnonymous]
@using Microsoft.AspNetCore.Authorization
@using Microsoft.AspNetCore.Authentication
@using Microsoft.AspNetCore.Authentication.Cookies
@using Microsoft.AspNetCore.Authentication.OpenIdConnect
@inject IHttpContextAccessor HttpContextAccessor

@code {
    protected override async Task OnInitializedAsync()
    {
        var httpContext = HttpContextAccessor.HttpContext;
        if (httpContext != null)
        {
            var properties = new AuthenticationProperties
            {
                RedirectUri = "/"
            };
            await httpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, properties);
            await httpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
        }
    }
}


Why sign out of TWO schemes?


This is where many implementations fail. OpenID Connect uses a dual authentication scheme:

  1. OpenIdConnect scheme: Handles the protocol dance with Keycloak (redirects, token exchange, logout).
  2. Cookie scheme: Manages the local session in your Blazor app.

When logging out, you must sign out of both, in this order:

  1. OIDC first: This redirects to Keycloak's logout endpoint, ending the SSO session.
  2. Cookie second: This clears the local authentication cookie.

Signing out of only the cookie leaves the Keycloak session active—users can click "Login" and get back in without re-entering credentials. Signing out only from OIDC leaves the local cookie intact, so the app still thinks they're logged in.

The RedirectUri in the authentication properties controls where users land after the Keycloak logout completes. We send them to the home page.

 

Step 7: The LoginDisplay Component

Now we need a UI element to show login state and provide login/logout actions. This typically lives in your app's header or navigation bar.

Note: NoteBookmark uses FluentUI Blazor (the <Fluent...> components), it's not a requirement, but it definitely looks great ;)

Create Components/Layout/LoginDisplay.razor:

@rendermode InteractiveServer
@using Microsoft.AspNetCore.Components.Authorization
@inject NavigationManager Navigation

<AuthorizeView>
    <Authorized>
        <FluentStack Orientation="Orientation.Horizontal" HorizontalGap="8" 
                     HorizontalAlignment="HorizontalAlignment.Right" 
                     VerticalAlignment="VerticalAlignment.Center">
            <span>Hello, @context.User.Identity?.Name</span>
            <FluentButton Appearance="Appearance.Lightweight" OnClick="Logout" 
                          IconStart="@(new Icons.Regular.Size16.ArrowExit())">
                Logout
            </FluentButton>
        </FluentStack>
    </Authorized>
    <NotAuthorized>
        <FluentButton Appearance="Appearance.Accent" OnClick="Login" 
                      IconStart="@(new Icons.Regular.Size16.Person())">
            Login
        </FluentButton>
    </NotAuthorized>
</AuthorizeView>

@code {
    private void Login()
    {
        var returnUrl = Navigation.ToBaseRelativePath(Navigation.Uri);
        if (string.IsNullOrEmpty(returnUrl)) returnUrl = "/";
        Navigation.NavigateTo($"/login?returnUrl={Uri.EscapeDataString(returnUrl)}", forceLoad: false);
    }

    private void Logout()
    {
        Navigation.NavigateTo("/logout", forceLoad: false);
    }
}

Key implementation details:

  • @rendermode InteractiveServer: This is essential. <AuthorizeView> needs to access AuthenticationStateProvider, which requires an interactive render mode. Without this, the component renders as static HTML and won't respond to auth state changes.
  • <AuthorizeView>: This component automatically shows/hides content based on authentication state. The context parameter provides access to the User claims principal.
  • Return URL on login: We pass the current page URL so users return to where they were after authenticating.
  • forceLoad: false: We use in-app navigation. The Login.razor and Logout.razor pages will handle the actual HTTP redirects.

Add this component to your MainLayout.razor or header component:<LoginDisplay />

visual of the LoginDisplay

Step 8: Protecting Routes and Pages

With login/logout working, you need to enforce authorization rules. Blazor provides two mechanisms: page-level protection with [Authorize] and inline content protection with <AuthorizeView>.

Updating Routes.razor

First, modify Components/Routes.razor to support authorization-aware routing:

@using Microsoft.AspNetCore.Components.Authorization
@using Microsoft.AspNetCore.Authorization

<FluentDesignTheme StorageName="theme" @rendermode="@InteractiveServer" />

<CascadingAuthenticationState>
    <Router AppAssembly="typeof(Program).Assembly">
        <Found Context="routeData">
            <AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
                <NotAuthorized>
                    @if (context.User.Identity?.IsAuthenticated != true)
                    {
                        <FluentStack Orientation="Orientation.Vertical" VerticalGap="20" 
                                     HorizontalAlignment="HorizontalAlignment.Center" 
                                     Style="margin-top: 100px;">
                            <FluentIcon Value="@(new Icons.Regular.Size48.LockClosed())" Color="Color.Accent" />
                            <h2>Authentication Required</h2>
                            <p>You need to be logged in to access this page.</p>
                            <FluentButton Appearance="Appearance.Accent" 
                                OnClick="@(() => NavigationManager.NavigateTo(
                                    "/login?returnUrl=" + Uri.EscapeDataString(
                                        NavigationManager.ToBaseRelativePath(NavigationManager.Uri)), 
                                    forceLoad: false))">
                                Login
                            </FluentButton>
                        </FluentStack>
                    }
                    else
                    {
                        <FluentStack Orientation="Orientation.Vertical" VerticalGap="20" 
                                     HorizontalAlignment="HorizontalAlignment.Center" 
                                     Style="margin-top: 100px;">
                            <FluentIcon Value="@(new Icons.Regular.Size48.ShieldError())" Color="Color.Error" />
                            <h2>Access Denied</h2>
                            <p>You don't have permission to access this page.</p>
                            <FluentButton Appearance="Appearance.Accent" 
                                OnClick="@(() => NavigationManager.NavigateTo("/", forceLoad: false))">
                                Go to Home
                            </FluentButton>
                        </FluentStack>
                    }
                </NotAuthorized>
            </AuthorizeRouteView>
            <FocusOnNavigate RouteData="routeData" Selector="h1" />
        </Found>
    </Router>
</CascadingAuthenticationState>

@code {
    [Inject] private NavigationManager NavigationManager { get; set; } = default!;
}


What changed?

  1. <CascadingAuthenticationState>: This wraps the entire router and makes authentication state available to all child components. Without it, <AuthorizeView> and [Authorize] attributes won't work.

  2. <AuthorizeRouteView>: Replaces the standard RouteView. This component checks the [Authorize] attribute on routed pages and enforces authorization rules.

  3. <NotAuthorized> with two states: This is subtle but important. The <NotAuthorized> content renders when authorization fails, but there are two scenarios:

    • Not authenticated (context.User.Identity?.IsAuthenticated != true): The user isn't logged in. Show a "Login" button.
    • Authenticated but not authorized (else): The user is logged in but lacks permission (e.g., wrong role). Show an "Access Denied" message.

Protecting Pages with [Authorize]

To require authentication for an entire page, add the [Authorize] attribute:

@page "/posts"
@attribute [Authorize]
@using Microsoft.AspNetCore.Authorization

<PageTitle>My Posts</PageTitle>

<h1>My Posts</h1>

<!-- Your protected content here -->

Now, unauthenticated users who navigate to /posts will see the "Authentication Required" message from Routes.razor, not the page content.

Note: [Authorize] also supports roles and policies (e.g. [Authorize(Roles = "Admin")]) for more granular access control, that's a topic for a future post.


Testing it out:

  1. Run your Aspire app host: dotnet run --project NoteBookmark.AppHost
  2. Navigate to your Blazor app in the browser.
  3. Click "Login"—you should redirect to Keycloak, authenticate, and return.
  4. You'll see "Hello, [your name]" in the header.
  5. Navigate to a page marked [Authorize] without logging in—you'll see the auth required message.
  6. Click "Logout"—you'll sign out of both the app and Keycloak.

Your Blazor app now has full OpenID Connect authentication with Keycloak, with a clean separation between the auth mechanics (Login/Logout pages), UI (LoginDisplay), and enforcement (Routes.razor + [Authorize]).


Conclusion

You've now integrated Keycloak authentication into your .NET Aspire application. The key pieces:

  1. Aspire orchestration: AddKeycloak(), .WithReference(), and .WaitFor() handle container lifecycle and configuration injection.
  2. OIDC pipeline: The standard ASP.NET Core authentication middleware, configured for Keycloak's OIDC endpoints.
  3. HTTP flexibility: Logic to handle HTTP Keycloak in dev while enforcing HTTPS in production.
  4. Persistent data: WithDataVolume() ensures your Keycloak realm config survives restarts.

This pattern scales beyond Keycloak, Aspire's resource model works the same way for databases, message queues, and other services. Once you've mastered .WithReference() and .WaitFor(), you can compose complex distributed systems with confidence.

The full working implementation is available in the NoteBookmark repository, including the AppHost configuration, Blazor components, and Docker Compose files referenced throughout this post.


Useful links


Exposing Home Container with Traefik and Cloudflare Tunnel

I love the cloud, in fact most people probably know me because of my shared content related to that. But sometimes our apps don't need scaling, or redundancy. Sometimes we just want to host them somewhere.

(post en français ici)

It was the holidays, and during my time off I worked on a few small personal projects. I packaged them in containers so it's easy to deploy anywhere. I deployed them on a mini-PC that I have at home and it is great... as long as I stay home. But what if I would like to access it from elsewhere (ex: my in-laws' house)?

I set up a nice Cloudflare tunnel to a Traefik container that proxies the traffic to the correct container based on the prefix or second-level domain. So dev.c5m.ca goes to container X and test.c5m.ca goes to container Y. In this post, I wanted to share how I did it (and also have it somewhere for me in case I need to do it again 😉). It's simple once you know all the pieces work together.

generated by Microsoft designer
generated by Microsoft designer

The Setup

The architecture is straightforward: Cloudflare Tunnel creates a secure connection from my home network to Cloudflare's edge, and Traefik acts as a reverse proxy that routes dynamically incoming requests to the appropriate container based on the subdomain. This way, I can access multi ple services through different subdomains without exposing my home network directly to the internet.

Step 1: Cloudflare Tunnel

First, assuming you already owne a domain name, you'll need to create a Cloudflare tunnel. You can do this through the Cloudflare dashboard under Zero Trust → Networks → Tunnels. Once created, you'll get a tunnel token that you'll use in the configuration.

Here's my cloudflare-docker-compose.yaml:

name: cloudflare-tunnel

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    container_name: cloudflared
    restart: unless-stopped
    env_file:
      - .env
    environment:
      - TUNNEL_TOKEN=${TUNNEL_TOKEN}
    command: ["tunnel", "--no-autoupdate", "run", "--token", "${TUNNEL_TOKEN}"]

The tunnel token is stored in a .env file for security. The --no-autoupdate flag prevents the container from trying to update itself automatically, which is useful in a controlled environment.

Step 2: DNS Configuration

In Cloudflare dashboard, create a CNAME Record with a wildcard. For example for my domain "c5m.ca" that record will look like this: *.c5m.ca.

Step 3: Traefik Configuration

Traefik is the reverse proxy that will route traffic to your containers. I have two configuration files: one for Traefik itself and one for the Docker Compose setup.

Here's my traefik.yaml:

global:
  checkNewVersion: false
  sendAnonymousUsage: false

api:
  dashboard: false #true
  insecure: true

entryPoints:
  web:
    address: :8082
  websecure:
    address: :8043

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false 

I've configured two entry points: web on port 8082 (HTTP) and websecure on port 8043 (HTTPS). I did it that way because the default 80 and 443 where already taken. The Docker provider watches for containers with Traefik labels and automatically configures routing. exposedByDefault: false means containers won't be exposed unless explicitly enabled with labels. You won't have to change Traefik config to add more containers, it's all dynamic.

And here's the traefik-docker-compose.yaml:

name: traefik

services:
  traefik:
    image: "traefik:v3.4"
    container_name: "traefik-app"
    restart: unless-stopped
    networks:
      - proxy

    ports:
      - "8888:8080" # Dashboard port
      - "8082:8082"
      - "8043:8043" # remap 443
    volumes:
      - "/var/run/docker.sock:/var/run/docker.sock:ro"
      - "./config/traefik.yaml:/etc/traefik/traefik.yaml:ro"

networks:
  proxy:
    name: proxy

The key points here:

  • Traefik is connected to a Docker network called proxy that will be shared with other containers. You can name it the way you like.
  • Port 8888 maps to Traefik's dashboard (currently disabled in the config)
  • Ports 8082 and 8043 are exposed for HTTP and HTTPS traffic
  • The Docker socket is mounted read-only so Traefik can discover containers
  • The configuration file is mounted from ./config/traefik.yaml

Step 4: Configuring Services

Now, any container you want to expose through Traefik needs to:

  1. Be on the same proxy network
  2. Have Traefik labels configured

Here's a simple example with an nginx container (nginx-docker-compose.yaml):

name: "test-tools"

services:
  nginx:
    image: "nginx:latest"
    container_name: "nginx-test"
    restart: unless-stopped
    networks:
      - proxy
    volumes:
      - "./html:/usr/share/nginx/html:ro"
      
    labels:
      - traefik.enable=true
      - traefik.http.routers.nginxtest.rule=Host(`test.c5m.ca`) 
      - traefik.http.routers.nginxtest.entrypoints=web

networks:
  proxy:
    external: true

The labels tell Traefik:

  • traefik.enable=true: This container should be exposed
  • nginxtest is the unique name for routing this container.
  • traefik.http.routers.nginxtest.rule=Host(...): Route requests for test.c5m.ca to this container
  • traefik.http.routers.nginxtest.entrypoints=web: Use the web entry point (port 8082)

Bonus: A More Complex Example

For a more realistic scenario, let's share how I could expose 2D6 Dungeon App here's a simplified version of my 2d6-docker-compose.yaml which includes a multi-container application:

name: 2d6-dungeon

services:
  database:
    container_name: 2d6_db
    ports:
      - "${MYSQL_PORT:-3306}:3306"
    networks:
      - proxy
    ...

  dab:
    container_name: 2d6_dab
    ...
    depends_on:
      database:
        condition: service_healthy
    ports:
      - "${DAB_PORT:-5000}:5000"
    networks:
      - proxy

  webapp:
    container_name: 2d6_app
    depends_on:
      - dab
    environment:
      ConnectionStrings__dab: http://dab:5000
      services__dab__http__0: http://dab:5000

    labels:
      - traefik.enable=true
      - traefik.http.routers.twodsix.rule=Host(`2d6.c5m.ca`)
      - traefik.http.routers.twodsix.entrypoints=web,websecure
      - traefik.http.services.twodsix.loadbalancer.server.port=${WEBAPP_PORT:-8080}

    networks:
      - proxy

    ports:
      - "${WEBAPP_PORT:-8080}:${WEBAPP_PORT:-8080}"

networks:
  proxy:
    external: true

This example shows:

  • Multiple services working together (database, API, web app)
  • Only the webapp is exposed through Traefik (the database and API are internal)
  • The webapp uses both web and websecure entry points
  • Important note here is that container part of the same network can use their internal port (ex: 5000 for DAB, 3306 for MySQL)
  • The external network is the proxy created previously

Cloudflare Tunnel Configuration

In your Cloudflare dashboard, you'll need to configure the tunnel to route traffic to Traefik. Create a public hostname that points to http://<local-ip>:8082. Use the local IP of your server something like "192.168.1.123" You can use wildcards like *.c5m.ca to route all subdomains to Traefik, which will then handle the routing based on the hostname.

Wrapping Up

That's it! Once everything is set up:

  1. The Cloudflare tunnel creates a secure connection from your home to Cloudflare
  2. Traffic comes in through Cloudflare and gets routed to Traefik
  3. Traefik reads the hostname and routes to the appropriate container
  4. Each service can be accessed via its own subdomain
  5. Only the containers with the Traefik labels are accessible from outside my network
  6. It's dynamic! Any new container, with the labels, will be routed without changing the config in Traefik nor Cloudflare

It's a simple setup that works great for personal projects. The best part is that you don't need to expose any ports on your router or deal with dynamic DNS, Cloudflare handles all of that.

Next step will be to add some authentication and authorization (ex: using Keycloak), but that's for another post. For now, this gives me a way to access my home-hosted services from anywhere, and I thought it could be useful to share.

Reading Notes #662

From Docker's security practices to the latest in GPT-5 discussions, there's quite a mix of topics to dive into. I particularly enjoyed the thought-provoking piece about junior developers in the age of LLMs - it's a conversation we should all be having.


As always, grab your favorite beverage, and let's explore what caught my attention this week!

DevOps

AI

Podcasts

Miscellaneous


Sharing my Reading Notes is a habit I started a long time ago, where I share a list of all the articles, blog posts, and books that catch my interest during the week.

If you have interesting content, share it!

~frank

Reading Notes #661

This week post collects concise links and takeaways across .NET, AI, Docker, open source security, DevOps, and broader developer topics. From the .NET Conf call for content and Copilot prompts to Docker MCP tooling, container debugging tips, running .NET as WASM, and a fresh look at the 10x engineer idea.


Suggestion of the week

AI

Open Source

DevOps

Programming

Miscellaneous


Sharing my Reading Notes is a habit I started a long time ago, where I share a list of all the articles, blog posts, and books that catch my interest during the week.

If you have interesting content, share it!

~frank

Reading Notes #660

This week’s notes cover GenAI vs agentic AI, fresh Docker and Aspire news, how to run WordPress in containers, and building apps with React and .NET. Plus a few podcasts worth a listen.


Enjoy!

AI

Open Source

  • Does it Make Sense to Run WordPress in Docker? (Lukas Mauser) - Looking at different options to run  WordPress? Check out this blog post. All the code to do it in a docker container is shared and also details the reasons why you should do it or not

Programming

Podcasts

Miscellaneous


Reading Notes #655

Welcome to the 655th Reading Notes. This edition explores embedding Python in .NET, working with stacked git branches, and an introduction to cloud-native. Plus, a quick tip for the Azure Portal and using local AI for code reviews. 

a kayak on the water with a tree at the horizon

Open Source

Programming

Cloud

AI


Sharing my Reading Notes is a habit I started a long time ago, where I share a list of all the articles, blog posts, and books that catch my interest during the week. 

If you have interesting content, share it!


~frank


Reading Notes #652

This week, we explore a variety of topics, from database containerization and AI security risks to the evolving landscape of gaming devices and cloud technologies. We also explore the shift towards security-first development and the integration of .NET Aspire with SQL Server for integration testing.


Let's dive in!

Suggestion of the week

Cloud

Programming

Databases

Miscellaneous

~frank

Reading Notes #651

Welcome to another edition of my reading notes! This week brings some fascinating insights into AI's real-world impact, exciting developments in .NET and containerization, plus practical tools for improving our development workflows. 
A duck in a city fontain

From local AI-powered code reviews to Docker security hardening and the upcoming .NET 10 features, there's plenty to explore.

 

AI

Programming

Cloud

Miscellaneous

  • Enhance productivity with AI + Remote Dev (Brigit Murtaugh, Christof Marti, Josh Spicer, Olivia Guzzardo McVicker) - I love the dev container environments, they are so useful! And I also use the remote one when I'm not on my dev device so easy. Happy to see that Copilot will be right there with me.
~frank

Full-Stack Azure Deployment Made Easy: Containers, Databases, and More

Automating deployments is something I always enjoy. However, it's true that it often takes more time than a simple "right-click deploy." Plus, you may need to know different technologies and scripting languages.

(Version française ici)

But what if there was a tool that could help you write everything you need—Infrastructure as Code (IaC) files, scripts to copy files, and scripts to populate a database? In this post, we'll explore how the Azure Developer CLI (azd) can make deployments much easier.

What do we want to do?

Our goal: Deploy the 2D6 Dungeon App to Azure Container Apps.

This .NET Aspire solution includes:

  • A frontend
  • A data API
  • A database

Aspire resources schema


The Problem

In a previous post, we showed how azd up can easily deploy web apps to Azure.

If we try the same command for this solution, the deployment will be successful—but incomplete:

  • The .NET Blazor frontend is deployed perfectly.
  • However, the app fails when trying to access data.
  • Looking at the logs, we see the database wasn't created or populated, and the API container fails to start.

Let's look more closely at these issues.

The Database

When running the solution locally, Aspire creates a MySQL container and executes SQL scripts to create and populate the tables. This is specified in the AppHost project:

var mysql = builder.AddMySql("sqlsvr2d6")
                   .WithLifetime(ContainerLifetime.Persistent);
                
var db2d6 = mysql.AddDatabase("db2d6");

mysql.WithInitBindMount(source: "../../database/scripts", isReadOnly: false);

When MySQL starts, it looks for SQL files in a specific folder and executes them. Locally, this works because the bind mount is mapped to a local folder with the files.

However, when deployed to Azure:

  • The mounts are created in Azure Storage Files
  • The files are missing!

The Data API

This project uses Data API Builder (dab). Based on a single config file, a full data API is built and hosted in a container.

Locally, Aspire creates a DAB container and reads the JSON config file to create the API. This is specified in the AppHost project:

var dab = builder.AddDataAPIBuilder("dab", ["../../database/dab-config.json"])
                .WithReference(db2d6)
                .WaitFor(db2d6);

But once again, when deployed to Azure, the file is missing. The DAB container starts but fails to find the config file.

Logs of DAB failing to start


The Solution

The solution is simple: the SQL scripts and DAB config file need to be uploaded into Azure Storage Files during deployment.

You can do this by adding a post-provision hook in the azure.yaml file to execute a script that uploads the files. See an example of a post-provision hook in this post.

Alternatively, you can leverage azd alpha features: azd.operations and infraSynth.

  • azd.operations extends the provisioning providers and will upload the files for us.
  • infraSynth generates the IaC files for the entire solution.

💡Note: These features are in alpha and subject to change.

Each azd alpha feature can be turned on individually. To see all features:

azd config list-alpha

To activate the features we need:

azd config set alpha.azd.operations on
azd config set alpha.infraSynth on

Let's Try It

Once the azd.operation feature is activated, any azd up will now upload the files into Azure. If you check the database, you'll see that the db2d6 database was created and populated. Yay!

However, the DAB API will still fail to start. Why? Because, currently, DAB looks for a file, not a folder, when it starts. This can be fixed by modifying the IaC files.

One Last Step: Synthesize the IaC Files

First, let's synthesize the IaC files. These Bicep files describe the required infrastructure for our solution.

With the infraSynth feature activated, run:

azd infra synth

You'll now see a new infra folder under the AppHost project, with YAML files matching the container names. Each file contains the details for creating a container.

Open the dab.tmpl.yaml file to see the DAB API configuration. Look for the volumeMounts section. To help DAB find its config file, add subPath: dab-config.json to make the binding more specific:

containers:
    - image: {{ .Image }}
      name: dab
      env:
        - name: AZURE_CLIENT_ID
          value: {{ .Env.MANAGED_IDENTITY_CLIENT_ID }}
        - name: ConnectionStrings__db2d6
          secretRef: connectionstrings--db2d6
      volumeMounts:
        - volumeName: dab-bm0
          mountPath: /App/dab-config.json
          subPath: dab-config.json
scale:
    minReplicas: 1
    maxReplicas: 1

You can also specify the scaling minimum and maximum number of replicas if you wish.

Now that the IaC files are created, azd will use them. If you run azd up again, the execution time will be much faster—azd deployment is incremental and only does "what changed."

The Final Result

The solution is now fully deployed:

  • The database is there with the data
  • The API works as expected
  • You can use your application!
2D6 Dungeon App deployed


Bonus: Deploying with CI/CD

Want to deploy with CI/CD? First, generate the GitHub Action (or Azure DevOps) workflow with:

azd pipeline config

Then, add a step to activate the alpha feature before the provisioning step in the azure-dev.yml file generated by the previous command.

- name: Extends provisioning providers with azd operations
  run: azd config set alpha.azd.operations on     

With these changes, and assuming the infra files are included in the repo, the deployment will work on the first try.

Conclusion

It's exciting to see how tools like azd are shaping the future of development and deployment. Not only do they make the developer's life easier today by automating complex tasks, but they also ensure you're ready for production with all the necessary Infrastructure as Code (IaC) files in place. The journey from code to cloud has never been smoother!

If you have any questions or feedback, I'm always happy to help—just reach out on your favorite social media platform.

In Video

Here the video version of this blog post.


References


Reading Notes #646

Welcome to this week's collection of fascinating reads across cloud computing, AI, and programming! As technology continues to evolve at breakneck speed, I've gathered some of the most insightful articles that caught my attention. From securing MCP servers to exploring Rust, there's something here for every tech enthusiast. 
Dive in and discover what's new in our rapidly changing digital landscape.

Cloud

AI

Programming

Sharing my Reading Notes is a habit I started a long time ago, where I share a list of all the articles, blog posts, and books that catch my interest during the week. 

If you have interesting content, share it!

~Frank

Converting a Blazor WASM to FluentUI Blazor server

TL;DR: This post walks through migrating a Blazor WebAssembly project to FluentUI Blazor server, highlighting key improvements in UI, authentication, and containerization using Azure Container Apps and .NET Aspire.

(👓Version en français ici

Why Migrate?

The migration to FluentUI Blazor server brought three major benefits:

  • 🎨 Modern UI with FluentUI components
  • 🔒 Simplified authentication using Azure Container Apps
  • 🚀 Better development experience with .NET Aspire

In this post, I'm sharing my journey while migrating a Blazor WebAssembly (WASM) project to a FluentUI Blazor server project. The goal was to use the new FluentUI Blazor components library, take advantage of .NET Aspire and be able to execute the project in a container.

Recently, I've been working on the migration of AzUrlShortener. Upgrading SDKs and packages, improving the security, and changing the architecture of the solution. This post is part of a series of posts where I share a few interesting details, tips, and tricks I learned while working on this project.

AzUrlShortener is an Open source project that consist of simple URL shortener that I built a few years ago. The goal was simple: having a budget friendly solution to share short URL that would be secure, easy to use and where the data would stay mine. Each instance is hosted in Azure and consist of an API (Azure Function), an Blazor WebAssembly website (Azure Static Web App), and Data Storage (Azure Storage table).

This post is part of a series about modernizing the AzUrlShortener project:

Migration Strategy: Fresh Start vs. Refactor

When migrating an existing project, you have two options: Editing the existing project to reshaping it into the new type or creating a new project and copy-pasting pieces of code from the old project to the new one. In this case, I chose to create a new project and copy-paste the code. This way, I could keep the old project as a backup in case something went wrong.

Creating a New Project

Like mentioned earlier I wanted to use the new FluentUI Blazor components library. It's an open-source project that provides a set of components for building web applications using the Fluent Design System. It makes it easy to create beautiful and responsive user interfaces that are consistent. To create a new project we can use the available template.

dotnet new fluentblazor -n Cloud5mins.ShortenerTools.TinyBlazorAdmin

Dark Mode & Theming Support 🌙

The one thing I do to all my FluentUI Blazor projects is to add a settings page. This page allows the user to change the theme and color of the application. I should do a template to save time, but until then here the required code to add the settings page.

Settings.razor

Let's start by creating that new page called Settings.razor. With two selects, one for the theme (dark or light) and one for the accent color.

@page "/settings"

@using Microsoft.FluentUI.AspNetCore.Components.Extensions

@rendermode InteractiveServer

<FluentDesignTheme @bind-Mode="@Mode"
				   @bind-OfficeColor="@OfficeColor"
				   StorageName="theme" />

<h3>Settings</h3>

<div>
	<FluentStack Orientation="Orientation.Vertical">
		<FluentSelect   Label="Theme" Width="150px"
						Items="@(Enum.GetValues<DesignThemeModes>())"
						@bind-SelectedOption="@Mode" />
		<FluentSelect   Label="Color"
						Items="@(Enum.GetValues<OfficeColor>().Select(i => (OfficeColor?)i))"
			Height="200px" Width="250px" @bind-SelectedOption="@OfficeColor">
			<OptionTemplate>
				<FluentStack>
					<FluentIcon Value="@(new Icons.Filled.Size20.RectangleLandscape())" Color="Color.Custom"
						CustomColor="@(@context.ToAttributeValue() != "default" ? context.ToAttributeValue() : "#036ac4" )" />
					<FluentLabel>@context</FluentLabel>
				</FluentStack>
			</OptionTemplate>
		</FluentSelect>
	</FluentStack>
</div>

@code {
    public DesignThemeModes Mode { get; set; }
    public OfficeColor? OfficeColor { get; set; }
}

App.razor

In the App it self, we need to some JavaScript and the loading theme component. Just after the </body> tag, we need to add the following code:

<!-- Set the default theme -->

<script src="_content/Microsoft.FluentUI.AspNetCore.Components/js/loading-theme.js" type="text/javascript"></script>

<loading-theme storage-name="theme"></loading-theme>

Imports.razor

I noticed some warning in the code about missing using directives. To fix that, find the line that reference to Components.Icons in the _Imports.razor and change it by the following. The Icons alias should resolve the problem.

@using Icons = Microsoft.FluentUI.AspNetCore.Components.Icons

MainLayout.razor

The main layout is our base page by default. We need to add Mode and OfficeColor to make the accessible to the entire application.

@code {
    public DesignThemeModes Mode { get; set; }
    public OfficeColor? OfficeColor { get; set; }
}

NavMenu.razor

The only thing left is to be able to easily access this new page. This can be done simply by adding an option in the navigation menu.

<FluentNavLink Href="/settings" Match="NavLinkMatch.All" Icon="@(new Icons.Regular.Size20.TextBulletListSquareSettings())">Settings</FluentNavLink>

Test it

And voilà! You should now have a settings page that allows you to change the theme and color of the application. This is all great and it's not really related to the migration, but it's a nice addition to have. Dark mode for the win!

The migration

The migration itself had many little pieces, but wasn't that complex. The project is part of a .NET Aspire solution, so I added the project to the solution dotnet sln add ./Cloud5mins.ShortenerTools.TinyBlazorAdmin. Then added the references to Cloud5mins.ShortenerTools.Core (the class library with all the model, and services) and the ServiceDefault project that was generated when we added Aspire to the solution.

The next logical step was to add our Blazor project the the orchestrator with those lines in the Program.cs of the AppHost project.

builder.AddProject<Projects.Cloud5mins_ShortenerTools_TinyBlazorAdmin>("admin")
	.WithExternalHttpEndpoints()
	.WithReference(manAPI);

This will make sure the TinyBlazorAdmin project starts with a reference to the API and have accessible endpoints. Without the .WithExternalHttpEndpoints() the project wouldn't be accessible once deployed to Azure.

To use the capability of .NET Aspire to orchestrate the different projects, we need to replace our previous HttpClient creation in the Program.cs of the TinyBlazorAdmin by the following code:

builder.Services.AddHttpClient<UrlManagerClient>(client => 
{
    client.BaseAddress = new Uri("https+http://api");
});

This will make sure the UrlManagerClient receives an httpClient using the correct address and port when calling the API. Let's have a look at the UrlManagerClient class and one of the method that will be used to call the API.

public class UrlManagerClient(HttpClient httpClient)
{

	public async Task<IQueryable<ShortUrlEntity>?> GetUrls()
    {
		IQueryable<ShortUrlEntity> urlList = null;
		try{
			using var response = await httpClient.GetAsync("/api/UrlList");
			if(response.IsSuccessStatusCode){
				var urls = await response.Content.ReadFromJsonAsync<ListResponse>();
				urlList = urls!.UrlList.AsQueryable<ShortUrlEntity>();
			}
		}
		catch(Exception ex){
			Console.WriteLine(ex.Message);
		}
        
		return urlList;
    }
	// ...
}

As the code shows the httpClient is injected in the constructor and used to call the API. The GetUrls method is a simple GET request that returns a list of ShortUrlEntity. The API is the one created in a previous post: How to use Azure Storage Table with .NET Aspire and a Minimal API, and all the classes are part of the Cloud5mins.ShortenerTools.Core project.

The URL Grid

Part of the migration was also to replace the Syncfusion grid by the new FluentUI Blazor Grid. Not that Syncfusion controls are not great, quite the contrary, but because the AzUrlShortener project has moved to a different owner, I think it would be better to use components that required no licenses.

For this initial iteration, the Syncfusion grid will be replace by the FluentUI Blazor Grid. In a future iteration the Syncfusion Charts component will also be replace. Thank you Syncfusion for the community license used in this project.

The code of UrlManager.razor changed quite a lot as the to grid were a bit different in there naming and usage. The sorting required a bit more code as the column name are not the same as the property name. To provide an example the "Vanity" column is in fact the RowKey property of the ShortUrlEntity class. To be able to sort the column, we need to create a GridSort object that will be used in the TemplateColumn definition.

Definition of the column in the grid:

<TemplateColumn Title="Vanity" Width="150px" Sortable="true" SortBy="@sortByVanities">
    <FluentAnchor Href="@context!.ShortUrl" Target="_blank" Aearance="Appearance.Hypeext">@context!.RowKey</FluentAnchor>
</TemplateColumn>

Definition of the GridSort object:

GridSort<ShortUrlEntity> sortByVanities = GridSort<ShortUrlEntity>.ByAscending(p => p.RowKey);

One big improvement that could be done in the future would be to use the virtual grid. The virtual grid is a great way to improve the performance of the grid when dealing with large amount of data as it only loads the data that is visible on the screen. I show how to use the virtual grid in a previous post: How use a Blazor QuickGrid with GraphQL.

Removing the fake popup div

One of the FluentUI Blazor component is the FluentUIDialogue. This component is used to display a popup window, and will help us keeping the code more structure and clean. Instead of having <div> in the code, we can typed <FluentUIDialog> and it will be rendered as a popup.

var dialog = await DialogService.ShowDialogAsync<NewUrlDialog>(shortUrlRequest, new DialogParameters()
	{
		Title = "Create a new Short Url",
		PreventDismissOnOverlayClick = true,
		PreventScroll = true
	});




Replacing the Authentication

Instead of having to implementing the authentication in the Blazor project, we will be using the a feature of Azure Container Apps that required no code changes! You don't need to change a single line of code to secure your application deployed on Azure Container Apps (ACA)! Instead, your application is automatically protected simply by enabling the authentication feature, called EasyAuth.

Once the solution is deployed to Azure the TinyBlazorAdmin will be installed in a container app named "admin". To secured it, navigate to the Azure Portal, and select the Container App you want to secure. In this case, it will be the "admin" container app. From the left menu, select Authentication and click Add identity provider.

You can choose between multiple providers, but let's use Microsoft since it's deployed in Azure and you are already logged in. Once Microsoft is chosen, you will see many configuration options. Select the recommended client secret expiration (e.g., 180 days). You can keep all the other default settings. Click Add. After a few seconds, you should see a notification in the top right corner that the identity provider was added successfully.

Voila! Your app now has authentication. Next time you navigate to the app, you will be prompted to log in with your Microsoft account. Notice that your entire app is protected. No page is accessible without authentication.

Conclusion

The migration from Blazor WebAssembly to FluentUI Blazor Server has been a successful journey that brought several meaningful improvements to the project:

  • Enhanced user interface with modern FluentUI components
  • Cleaner, more maintainable code structure
  • Simplified authentication using Azure Container Apps' EasyAuth
  • Improved local development experience with .NET Aspire orchestration

The end result is a modern, containerized application that's both easier to maintain and more pleasant to use. The addition of dark mode support and theming capabilities are great improvements to the user experience.

Want to Learn more?

To learn more about Azure Container Apps I strongly suggest this repository: Getting Started .NET on Azure Container Apps, it contains many step-by-step tutorials (with videos) to learn how to use Azure Container Apps with .NET.

Have questions about the migration process or want to share your own experiences with FluentUI Blazor? Feel free to reach out to me on @fboucheros.bsky.social or open an issue on the AzUrlShortener GitHub repository. I'd love to hear your thoughts!


References

Reading Notes #644

This post gathers my recent reading notes on artificial intelligence, programming, and a few inspiring podcasts. It includes links to articles, tutorials, and fascinating discussions. Whether you're interested in the latest AI developments, .NET tools, or modern architectures, there's plenty here to spark your curiosity. 


Happy reading!

AI


Programming


Podcasts

Sharing my Reading Notes is a habit I started a long time ago, where I share a list of all the articles, blog posts, and books that catch my interest during the week. 

If you have interesting content, share it!

~Frank