Chapter 18 · Deployment

Self-Hosted, Non-Azure Docker

Everything so far assumed Azure: Azure OpenAI, Azure Key Vault, Microsoft Entra sign-in. None of that is required. This page runs the harness headless — API and real-time messaging only, no web dashboard — in a container on a plain Linux box, an x64 workstation, or an Apple Silicon Mac, with zero Azure configuration anywhere. This is the exact path proven while adopting the harness as the runtime for a Discord-based AI companion running on a Mac Mini with no Azure tenant at all (issue #591).

i
Which host is this?

Presentation.AgentHub — the same SignalR-based service you'd otherwise run with dotnet run. Nothing new was built to make this work; the composition root (The Big Picture) already builds cleanly with zero Azure configuration. This page is about packaging that fact into a container and closing the two real gaps that only show up once Azure identity genuinely isn't there.

What you get, and what you give up

Read this before you deploy — the trade-offs are deliberate, not oversights, but you should make them with eyes open.

No web dashboard
The container build runs in Release configuration, which drops the two frontend projects entirely. You get the HTTP API and the SignalR hub — no browser UI ships in this image.
No sign-in, by default
A self-hosted deployment usually has no Microsoft Entra tenant to authenticate against. The shipped container configuration turns sign-in off entirely (details below) rather than silently failing every request. Put this behind a private network or a reverse proxy with its own authentication if that matters to your deployment.
No telemetry export, by default
With no observability collector running alongside it, the container ships with trace/log export turned off rather than emitting failed export attempts on a loop. Turn it back on with two settings if you run a collector (Observability & Safety covers what that buys you).
Any OpenAI-compatible provider still works
This deployment mode changes nothing about model access. OpenRouter (proven end-to-end), plain OpenAI, or any OpenAI-compatible gateway all work exactly as in Get Running in 10 Minutes.

Build and run

One Dockerfile builds images for both Intel/AMD (x64) and Apple Silicon/ARM servers (arm64) machines — the underlying publish is framework-dependent, so no per-architecture build step is needed.

!
Build context is the repository root, not src/

Presentation.AgentHub pulls its skill and agent definitions from two folders that live above src/. Building from src/ alone (the pattern the Azure/Foundry container uses) builds and boots without error, then fails every agent-turn request because no agents were ever packaged into the image. The commands below already account for this.

  1. Step 1
    Configure your provider

    From the repository root:

    bash
    cp deploy/agenthub/.env.example deploy/agenthub/.env
    # edit deploy/agenthub/.env: set your provider API key, endpoint, and model

    deploy/agenthub/.env is already excluded from version control. Never put real credentials in any tracked appsettings*.json file — see Configuration Reference for the full secrets story.

  2. Step 2
    Build and run
    bash
    docker compose -f deploy/agenthub/docker-compose.yml --env-file deploy/agenthub/.env up --build

    For a multi-architecture image outside compose (e.g. to push to a registry both an x64 CI runner and an arm64 device will pull from):

    bash
    docker buildx build --platform linux/amd64,linux/arm64 \
      -f src/Content/Presentation/Presentation.AgentHub/Dockerfile \
      -t agenthub:latest .
  3. Step 3
    Verify
    bash
    curl http://localhost:8080/health/ai
    curl http://localhost:8080/health/subsystems

    /health/ai confirms the model provider is reachable. /health/subsystems is new to this deployment path — see below for what it reports and why it exists.


The two container-specific gotchas

Both were found the hard way, on real hardware, during the harness's own adoption as a Discord companion's runtime. Both are already fixed in the shipped Dockerfile and container settings — this section explains why they exist, in case you hit a variant of either one.

1 · Kestrel needs both endpoints overridden, not just one

The default configuration binds an HTTP and an HTTPS endpoint. A fresh container has no TLS certificate, so if only the HTTP endpoint is redirected to a plain port, the host still fails outright at startup trying to configure HTTPS. The Dockerfile sets both:

Dockerfile
ENV Kestrel__Endpoints__Http__Url=http://+:8080 \
    Kestrel__Endpoints__Https__Url=http://+:8081

Also note that ASPNETCORE_URLS is silently ignored whenever Kestrel:Endpoints is set in configuration — which it is here — so that environment variable is not a working alternative.

2 · Prompt caching and the storage paths need Linux-shaped values

The default configuration ships with a Windows-style conversation storage path, which only matters on Linux the moment a conversation is actually written. The container's own settings file redirects every persistent path — conversation history, planner state, governance state, logs — to one directory, mounted as a single Docker volume so nothing is lost on restart.


Authentication: off by default, and why

The harness normally requires signing in through Microsoft Entra. A self-hosted deployment usually has no Entra tenant at all, so the container ships with two settings that together turn sign-in off entirely:

appsettings.Container.json
"Auth": {
  "Disabled": true,
  "AllowOutsideDevelopment": true
}

Both flags are required together, deliberately. Auth:Disabled alone only takes effect in the Development environment — it does nothing in a production-configured container. AllowOutsideDevelopment is the explicit, reviewable decision to accept that trade-off outside Development, instead of the alternative of mislabeling a real deployment as "Development" (which also silently turns off unrelated hardening, like forced HTTPS).

!
This means anyone who can reach the port can use it

Treat network placement as your access control here: put this behind a private network, a VPN, or a reverse proxy that enforces its own authentication before traffic reaches the container. If you do have an Entra tenant, set both flags back to false and configure Entra normally — everything else on this page still applies.

"Use it" means ordinary agent turns only — the auto-authenticated identity this mode grants deliberately holds no elevated roles. It cannot approve escalations, decide change proposals, or trigger drift/registry operate actions. Someone who reaches the port can talk to the agent; they cannot administer the deployment.


Verifying the no-Azure guarantee

Two independent layers guarantee this container never reaches out to Azure for configuration, and you can check both yourself rather than take it on faith:

  1. An explicit DisableAzureConfigSources flag, set both as a container environment variable and in the shipped settings file, forces Azure Key Vault and Azure App Configuration off regardless of anything else present in the environment.
  2. GET /health/subsystems reports, among other things, whether either Azure configuration provider actually loaded at startup — not merely whether the flag is set, but whether it worked:
json
{
  "aiProvider": "OpenAI",
  "aiProviderConfigured": true,
  "authMode": "disabled",
  "azureKeyVaultConfigSourceLoaded": false,
  "azureAppConfigurationSourceLoaded": false,
  "cacheBackend": "None",
  "sandboxEnabled": true,
  "governanceEnabled": true,
  "otlpEnabled": false
}

This endpoint reports names, enum values, and booleans only — the same convention /health/ai and GET /api/config/status already follow. It never returns a secret, a connection string, or a file path.


What just happened

Nothing about the harness's core composition changed for this deployment mode — every Azure service registration was already conditional on Azure configuration actually being present. What this page added was the missing packaging and two pieces of hardening that only matter once that configuration is genuinely, permanently absent: a real sign-in off-switch that doesn't require lying about the environment, and an explicit, verifiable guarantee that no Azure configuration source will ever be attempted.

Where to go from here