Dockerizing for nginx

A multi-stage Dockerfile that builds the .NET API into a small runtime image, plus an nginx config that reverse-proxies to it — the same "build once, serve the output" shape as this docs site's own npm run build → nginx flow, just for an API instead of static files.

Multi-stage Dockerfile

Two stages: the build stage has the full SDK and compiles the app; the final stage only has the lightweight ASP.NET runtime and the already-compiled output. This keeps the shipped image small — no compiler, no source, no NuGet cache sitting in production.

Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src

# copy csproj files first so `dotnet restore` is cached
# separately from source changes
COPY ["YourApp.API/YourApp.API.csproj", "YourApp.API/"]
COPY ["YourApp.Application/YourApp.Application.csproj", "YourApp.Application/"]
COPY ["YourApp.Domain/YourApp.Domain.csproj", "YourApp.Domain/"]
COPY ["YourApp.Infrastructure/YourApp.Infrastructure.csproj", "YourApp.Infrastructure/"]
RUN dotnet restore "YourApp.API/YourApp.API.csproj"

COPY . .
WORKDIR /src/YourApp.API
RUN dotnet publish -c Release -o /app/publish --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
COPY --from=build /app/publish .

EXPOSE 8080
ENTRYPOINT ["dotnet", "YourApp.API.dll"]

docker-compose: API + database + nginx

nginx sits in front of the API container so the API itself is never exposed directly — nginx handles TLS termination, and the same nginx also happens to be exactly where the static docs site from earlier in this project would be served from.

docker-compose.yml
services:
  api:
    build:
      context: .
      dockerfile: YourApp.API/Dockerfile
    environment:
      - ConnectionStrings__DefaultConnection=Server=db;Database=YourApp;User=sa;Password=${DB_PASSWORD};TrustServerCertificate=True
      - JWT__Key=${JWT_KEY}
      - JWT__Issuer=YourApp
    depends_on:
      - db
    restart: unless-stopped

  db:
    image: mcr.microsoft.com/mssql/server:2022-latest
    environment:
      - ACCEPT_EULA=Y
      - SA_PASSWORD=${DB_PASSWORD}
    volumes:
      - db-data:/var/opt/mssql
    restart: unless-stopped

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
      - /var/www/docs:/var/www/docs:ro
    depends_on:
      - api
    restart: unless-stopped

volumes:
  db-data:

Secrets (DB_PASSWORD, JWT_KEY) come from a .env file next to docker-compose.yml — never committed, never baked into the image.

nginx: reverse proxy + static docs, side by side

nginx.conf
server {
    listen 80;
    server_name yourapp.example.com;

    # API — proxied to the container on the compose network
    location /api/ {
        proxy_pass http://api:8080/;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Docs site — plain static files, served through a "current" symlink
    # rather than a fixed path, so a new release goes live by re-pointing
    # one symlink instead of touching nginx at all (see Versioned Releases
    # & Auto-Deploy).
    location /docs/ {
        alias /var/www/docs/current/;
        try_files $uri $uri.html $uri/ =404;
    }
}

No nginx -s reload is needed when a new release ships: nginx resolves /var/www/docs/current/ on every request, so it always follows wherever that symlink currently points — see Versioned Releases & Auto-Deploy for how the symlink gets flipped.

Building and running

shell
docker compose up -d --build
docker compose logs -f api
The COPY *.csproj step before COPY . . is worth keeping even though it looks redundant: Docker caches each layer, so as long as no .csproj changed, dotnet restore is skipped entirely on the next build — only source changes trigger a re-restore.