Skip to content

Image anatomy

What a Protequ adapter image is, how to build one from a standalone repository, and what the runtime environment guarantees your container.


The shape of the image

Property Value Why
Platform linux/amd64 The installer resolves the linux/amd64 manifest when reading labels; other platforms are ignored.
Base (headless adapter) mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled-extra Small, no shell, no package manager. -extra adds ICU/globalization, which the framework needs.
Base (adapter with a UI) mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled Adds the ASP.NET Core runtime for the self-served UI + API.
User $APP_UID (non-root, provided by the base image) Adapters must not run as root.
Entrypoint ["dotnet", "<YourAssembly>.dll"] Published with -p:UseAppHost=false, so there is no native launcher.
Ports none for a headless adapter Adapters reach out to NATS; nothing connects in.

Chiseled images have no shell. RUN, CMD-SHELL healthchecks and sh -c do not work in the final stage. Do all your shell work in the build stage.


The project file

Your adapter is an ordinary .NET executable that references the framework package. You do not need the Protequ monorepo — the package carries the source generator, the framework YAML definitions and the bundled Pq.Domain / Pq.Messaging assemblies.

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <!-- "*" restores the newest published package, the one this documentation describes. -->
    <PackageReference Include="Pq.Adapters.Framework" Version="*" />
    <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.0" />
  </ItemGroup>

  <ItemGroup>
    <!-- Your own YAML must be handed to the source generator explicitly.
         The framework's own YAML files are added automatically by the package. -->
    <AdditionalFiles Include="adapter-registration.yaml" />
    <AdditionalFiles Include="access-model.yaml" />
  </ItemGroup>

  <ItemGroup>
    <None Update="appsettings.json">
      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
    </None>
  </ItemGroup>

</Project>

Add the feed once on your machine:

dotnet nuget add source https://repo.protequ.com/api/packages/PQ/nuget/index.json -n pq -u <your-username> -p <your-token> --store-password-in-clear-text

On Windows you can drop --store-password-in-clear-text and the password is stored DPAPI-encrypted in your user-level NuGet.Config. On Linux/macOS the flag is required.


A complete Dockerfile

Assumes this layout, with the build context at the repository root:

pq-adapter-acme/
├─ Dockerfile
└─ src/Pq.Adapter.Acme/
   ├─ Pq.Adapter.Acme.csproj
   ├─ adapter-registration.yaml
   ├─ access-model.yaml
   ├─ appsettings.json
   └─ Program.cs
# ─── build ────────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release

# PQ NuGet feed credentials. Declared in THIS stage only, so they never reach
# the published image (the final stage below does not inherit them).
ARG NUGET_SOURCE_URL
ARG NUGET_SOURCE_USER
ARG NUGET_SOURCE_PASS

WORKDIR /src

# Restore first, on the csproj alone, so the layer caches across code changes.
COPY ["src/Pq.Adapter.Acme/Pq.Adapter.Acme.csproj", "src/Pq.Adapter.Acme/"]
RUN if [ -n "$NUGET_SOURCE_URL" ]; then \
      dotnet nuget add source "$NUGET_SOURCE_URL" -n pq \
        -u "$NUGET_SOURCE_USER" -p "$NUGET_SOURCE_PASS" \
        --store-password-in-clear-text; \
    fi && \
    dotnet restore "./src/Pq.Adapter.Acme/Pq.Adapter.Acme.csproj"

COPY . .
WORKDIR /src/src/Pq.Adapter.Acme
RUN dotnet publish "./Pq.Adapter.Acme.csproj" \
      -c $BUILD_CONFIGURATION \
      -o /app/publish \
      --no-restore \
      -p:UseAppHost=false

# ─── final ────────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/runtime:10.0-noble-chiseled-extra AS final
WORKDIR /app

LABEL org.opencontainers.image.title="ACME Access Controller" \
      org.opencontainers.image.description="ACME AC-2000 door controllers over TCP — card and PIN readers, doors, relay outputs and buffered access events." \
      org.opencontainers.image.vendor="ACME Security s.r.o." \
      org.opencontainers.image.source="https://github.com/acme/pq-adapter-acme" \
      org.opencontainers.image.licenses="Proprietary" \
      org.protequ.adapter="true" \
      org.protequ.category="access" \
      org.protequ.device_vendor="ACME Security s.r.o." \
      org.protequ.capabilities="access,outputs" \
      org.protequ.default_enabled="false"

COPY --chown=$APP_UID:$APP_UID --from=build /app/publish .

# Documentation bundle — the YAML metadata, assessment, and documentation pages — copied into the image
# and pointed at by a label, so the image is a single deliverable. Protequ extracts it and
# renders your pages. See [Writing and delivering adapter documentation](../documentation-deliverable.md).
COPY src/Pq.Adapter.Acme/adapter-registration.yaml \
     src/Pq.Adapter.Acme/access-model.yaml \
     src/Pq.Adapter.Acme/capabilities-coverage.yaml \
     /pq/adapter-docs/Pq.Adapter.Acme/
COPY src/Pq.Adapter.Acme/docs /pq/adapter-docs/Pq.Adapter.Acme/docs
LABEL org.protequ.docs="/pq/adapter-docs/Pq.Adapter.Acme"

ENTRYPOINT ["dotnet", "Pq.Adapter.Acme.dll"]

Build it:

docker build -t pqacme:1.0.0 --build-arg NUGET_SOURCE_URL=https://repo.protequ.com/api/packages/PQ/nuget/index.json --build-arg NUGET_SOURCE_USER=<user> --build-arg NUGET_SOURCE_PASS=<token> .

Why the credentials are safe here. ARG NUGET_SOURCE_PASS is declared only in the build stage, and the final stage starts from a fresh FROM. None of the build stage's layers or metadata end up in the pushed image. If your policy forbids build args for secrets entirely, use BuildKit secrets (RUN --mount=type=secret,id=nuget_pass …) instead.


Runtime contract

The installer generates the compose service for your adapter from the image name plus your labels. This is what it writes — and therefore what your container can rely on:

  adapter-acme:                                     # "pq" stripped from the image name
    image: ${REGISTRY}/${OWNER}/pqacme:${PQACME_TAG:-${TAG:-latest}}
    container_name: adapter-acme
    profiles: [pqacme]                              # compose profile == image name
    restart: unless-stopped
    depends_on:
      server:
        condition: service_healthy
    environment:
      Mq__Host: "${MQ_HOST}"
      Mq__User: "${MASTER_USER}"
      Mq__Password: "${MASTER_PASSWORD}"
      OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT:-}"

Environment variables you always get

Variable Meaning
Mq__Host NATS endpoint inside the compose network, e.g. nats:4222.
Mq__User / Mq__Password NATS credentials.
OTEL_EXPORTER_OTLP_ENDPOINT OTLP collector for logs/traces. May be empty — handle that.

The double underscore is .NET configuration binding: Mq__Host overrides Mq:Host from your appsettings.json. Keep a placeholder section there so the shape is obvious, and supply the real values from the environment:

{
  "Mq": { "Host": "localhost:4222", "User": "", "Password": "" }
}

Never commit working NATS credentials — not to appsettings.json, not to the image, not to the docs. In deployment they arrive from compose; for local runs keep them in appsettings.Development.json (git-ignored) or ~/.pq/adapter-dev.json.

Everything else — the server API base URL and the X-Api-Key your adapter uses for /Internal/* — is delivered at registration time over NATS. Do not expect it from the environment, and do not require the operator to configure it.

Adapters never call the operator API. The adapter's API key carries no write permission; a PUT to /Api/… fails and crash-loops the container. Adapters receive commands by subscribing to pq.command.{assignedId} on NATS.

Declaring anything extra

If your adapter needs a published port, a bind mount, an extra environment variable or a dependency on another service, declare it with a label — the installer will fold it into the generated service. Never ask the customer to hand-edit compose. See Deployment labels.


Adapters that ship a UI

An adapter can serve its own Blazor WASM UI plus a backend API. Add the ASP.NET base image, an EXPOSE, and the three UI labels:

FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final
WORKDIR /app
EXPOSE 8443
LABEL org.protequ.adapter="true" \
      org.protequ.category="module" \
      org.protequ.ui="true" \
      org.protequ.ui.slug="acme" \
      org.protequ.ui.port="8443"

The installer then writes an nginx block routing the customer's front door to your container:

location /adapters/acme/ {
    rewrite ^/adapters/acme/(.*)$ /$1 break;
    proxy_pass https://adapter-acme:8443;
    proxy_ssl_verify off;
    …forwards Host, X-Forwarded-*, Cookie and Authorization
}

Consequences for your app:

  • Serve HTTPS on the declared port. The certificate is not validated (proxy_ssl_verify off), so a self-signed cert is fine.
  • The path prefix is stripped before the request reaches you — serve from /, and make the WASM base href work under /adapters/<slug>/ on the client side.
  • WebSocket upgrade is forwarded, so SignalR works.
  • The user's Authorization header and cookies are forwarded — validate them against Keycloak yourself.

UI adapters are tightly coupled to the server version. An adapter client assembly is loaded into the main app's runtime, so its Pq.Client.Shared must match the deployed pqserver. Release a UI adapter together with the server version it was built against.