Files
DodoSSH/src/DodoSSH.Api/Dockerfile
T
jaap-jan 902761d37b
ci / build and test (push) Successful in 3m38s
ci / desktop nightly (push) Successful in 1m42s
ci / api image (push) Successful in 20s
ci / deploy ssh.dodotech.cloud (push) Failing after 0s
ci / android head (push) Successful in 6m3s
CI: deploy ssh.dodotech.cloud after the image is pushed, and fail unless it comes back ready
The image job pushed :main and stopped there, so nothing told the Spelunk
stack a new image existed and a commit carrying a migration never reached
the server's schema.

A deploy job now calls the stack's deploy hook with SPELUNK_DEPLOY_TOKEN and
then waits up to ten minutes for /healthz/ready. The new container applies
pending migrations as it starts, and readiness stays unhealthy while any is
pending, so a migration that did not apply fails the run instead of passing
quietly.

The Dockerfile no longer says nothing migrates from inside the container,
which had stopped being true and pointed at the setting that causes this.
2026-10-07 11:15:20 +02:00

147 lines
8.5 KiB
Docker

# The API image. Built from the repository root, not from this directory:
#
# docker build -f src/DodoSSH.Api/Dockerfile .
#
# Directory.Build.props, Directory.Build.targets, Directory.Packages.props, NuGet.config and
# global.json all sit at the root and all five 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.
#
# Directory.Build.targets is the one that is easy to forget, because leaving it out breaks
# nothing until the day it does: it pins the version of the trimmer the SDK adds to
# DodoSSH.Contracts and DodoSSH.Crypto by itself (see the comment there). Without it the restore
# below asks for whatever ILLink the image's SDK bundles, which matches the lock files only
# until .NET ships a servicing release — and then this image fails with NU1004 while ci.yml,
# which does see the pin, stays green. That is how it was found: sdk:10.0-noble moved to
# 10.0.401 (ILLink 10.0.12) and this build broke with no commit touching a package.
# ---------------------------------------------------------------------------------------
# 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.Build.targets 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-<short sha>` 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.
#
# The process migrates the database itself as it starts, before its port opens and under an
# advisory lock so two replicas cannot race (Database:AutoMigrate, on by default). Leave it on
# for ssh.dodotech.cloud: the CI deploy job relies on it. Turned off, /healthz/ready fails
# while a migration is pending and names it, and something else has to apply the schema.
ENTRYPOINT ["dotnet", "DodoSSH.Api.dll"]