# The API image. Built from the repository root, not from this directory: # # docker build -f src/DodoSSH.Api/Dockerfile . # # Directory.Build.props, Directory.Packages.props, NuGet.config and global.json all sit at # the root and all four are load-bearing here — central package management means a csproj # alone does not name a single version, and a build that cannot see them resolves nothing. # --------------------------------------------------------------------------------------- # Build # --------------------------------------------------------------------------------------- FROM mcr.microsoft.com/dotnet/sdk:10.0-noble AS build # Reproducible builds. Directory.Build.props turns ContinuousIntegrationBuild on when this # is set, which is what normalises the source paths baked into the PDBs — without it two # builds of the same commit differ by the directory they happened in. ENV CI=true \ DOTNET_NOLOGO=true \ DOTNET_CLI_TELEMETRY_OPTOUT=true \ DOTNET_SKIP_FIRST_TIME_EXPERIENCE=true WORKDIR /src # The manifests first, and only the manifests. This layer is what makes an ordinary code # change a ten-second rebuild instead of a full package restore: it is invalidated by a # dependency change and by nothing else. Every project in DodoSSH.Api's reference closure # has to be here — restore walks ProjectReference, and a missing csproj fails the graph # rather than skipping a node. COPY global.json NuGet.config Directory.Build.props Directory.Packages.props ./ COPY src/DodoSSH.Api/DodoSSH.Api.csproj src/DodoSSH.Api/ COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/ COPY src/DodoSSH.Contracts/DodoSSH.Contracts.csproj src/DodoSSH.Contracts/ COPY src/DodoSSH.Contracts/packages.lock.json src/DodoSSH.Contracts/ COPY src/DodoSSH.Crypto/DodoSSH.Crypto.csproj src/DodoSSH.Crypto/ COPY src/DodoSSH.Crypto/packages.lock.json src/DodoSSH.Crypto/ COPY src/DodoSSH.Domain/DodoSSH.Domain.csproj src/DodoSSH.Domain/ COPY src/DodoSSH.Domain/packages.lock.json src/DodoSSH.Domain/ COPY src/DodoSSH.Infrastructure/DodoSSH.Infrastructure.csproj src/DodoSSH.Infrastructure/ COPY src/DodoSSH.Infrastructure/packages.lock.json src/DodoSSH.Infrastructure/ # Locked mode here for the same reason CI uses it: the lock files are committed, so a # dependency that changed without its lock file being reviewed fails the build rather than # quietly shipping. An image is the one place that matters most. RUN dotnet restore src/DodoSSH.Api/DodoSSH.Api.csproj --locked-mode # BannedSymbols.txt is an AdditionalFiles entry in Directory.Build.props. Without it the # BannedApiAnalyzers rules silently pass, and with TreatWarningsAsErrors the whole point of # the list is that it fails a build — so its absence would be invisible in exactly the way # it is meant to prevent. COPY BannedSymbols.txt .editorconfig ./ COPY src/ src/ # The version, handed in rather than derived, because there is no repository in here to derive # it from: MinVer reads git tags, and .dockerignore excludes .git/ deliberately — the context is # the repository root and copying the whole history into every image build would be absurd. # # Without this the build still succeeds (MINVER1001 is a warning, and TreatWarningsAsErrors does # not escalate a task warning), and that is the trap: the image would be built with the SDK's # fallback version and GET /api/v1/meta would report 0.0.0-alpha.0 as its serverVersion, which is # a lie told quietly. The tag is already parsed by the workflow for the image tags, so it is the # same number, passed one step further. # # MinVerSkip because there is nothing here for it to do, and it should not warn about it either. # # ASSEMBLY_VERSION and emphatically not VERSION, which is the trap this block exists to avoid and # which cost a build to find. An ARG is an environment variable for the rest of the stage, MSBuild # reads environment variables as global properties, and property names are case-insensitive — so an # `ARG VERSION` in a build stage silently sets MSBuild's `Version` for every project in it. With the # workflow passing `main-` on a main build, that is not a version the SDK will accept, and # the publish dies with NETSDK1018 "Invalid NuGet version string" pointing at DodoSSH.Contracts, a # project nobody changed. The name is the whole fix; the ARG in the final stage below is only ever a # label and never meets MSBuild. # # The workflow passes this empty except on a tag build, so a main image keeps the SDK default rather # than carrying a version that is not one. ARG ASSEMBLY_VERSION="" RUN dotnet publish src/DodoSSH.Api/DodoSSH.Api.csproj \ --no-restore \ --configuration Release \ --output /app \ -p:MinVerSkip=true \ ${ASSEMBLY_VERSION:+-p:Version="$ASSEMBLY_VERSION"} \ -p:UseAppHost=false # --------------------------------------------------------------------------------------- # Runtime # --------------------------------------------------------------------------------------- # # Chiseled: no shell, no package manager, no libc utilities, and a non-root user (uid 1654) # already set by the base image. That closes off `docker exec sh` on a process that holds a # database connection and the cursor signing key, and it is affordable here specifically # because Directory.Build.props sets InvariantGlobalization — the ICU and tzdata a normal # base carries are exactly what this product has already decided it does not use. # # The cost is real and worth stating: there is no HEALTHCHECK below, because there is no # curl and nothing to run one with. The health endpoints exist and are anonymous — # /healthz/live, /healthz/ready, /healthz/startup — so the probe belongs in whatever runs # the container. Readiness is not decoration on this API: it fails while an EF migration is # pending and names the one it is waiting for, which is the intended way to discover that a # deployment shipped ahead of its schema. FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble-chiseled AS final # Passed by CI; see .github/workflows/ci.yml. Declared with empty defaults so a local # `docker build` with no arguments still succeeds. ARG VERSION="" ARG REVISION="" ARG CREATED="" LABEL org.opencontainers.image.title="DodoSSH API" \ org.opencontainers.image.description="DodoSSH server: sync, identity, teams and relay authorization." \ org.opencontainers.image.vendor="DodoTech" \ org.opencontainers.image.licenses="MIT" \ org.opencontainers.image.source="https://git.dodotech.cloud/DodoTech-Public/DodoSSH" \ org.opencontainers.image.version="${VERSION}" \ org.opencontainers.image.revision="${REVISION}" \ org.opencontainers.image.created="${CREATED}" WORKDIR /app COPY --from=build /app . # 8080 is the .NET container default (ASPNETCORE_HTTP_PORTS in the base image), and plain # HTTP is deliberate: Program.cs has no UseHttpsRedirection because the API is always behind # a proxy that terminates TLS, and redirecting from here would loop. EXPOSE 8080 # Configuration reaches the process two ways, both already wired in Program.cs: environment # variables prefixed DODOSSH_, and files under /run/secrets for anything that should not be # readable in `docker inspect`. The three the process will not start or run correctly # without are DODOSSH_ConnectionStrings__Postgres, DODOSSH_Oidc__Authority and — on more # than one node — DODOSSH_Sync__CursorSigningKey. # # Nothing migrates the database from in here. That is the API's own design: it fails # /healthz/ready while a migration is pending and names it, so the schema is applied by # `dotnet ef database update` alongside the deployment rather than by a racing container. ENTRYPOINT ["dotnet", "DodoSSH.Api.dll"]