Skip to content

Latest commit

 

History

History
421 lines (320 loc) · 13.8 KB

docker-instructions.md

File metadata and controls

421 lines (320 loc) · 13.8 KB
applyTo docker-compose.yml,**/Dockerfile,**/*.dockerfile,.env,.env.example

Docker Instructions

This document defines Docker conventions for the backend-only deployment of this project.

Current scope: API + infrastructure services only. Do not add frontend build mounts, React SPA containers, or static-site serving rules to Docker until the product explicitly grows a frontend deployment surface.


Project Layout (Docker perspective)

CleanArcStack/
├── docker-compose.yml           # All containers: postgres, redis, rabbitmq, elasticsearch,
│                                # kibana, kibana-setup, minio, api, nginx
├── .env                         # Non-sensitive config + secret file paths (NOT in Git)
├── .env.example                 # Template committed to Git — mirrors .env structure
├── .secrets/                    # One file per secret, no trailing newline (NOT in Git)
├── nginx/
│   └── nginx.conf               # Reverse proxy + MinIO CDN config
└── src/
    └── CleanArcStack.API/
        └── Dockerfile           # Multi-stage build for the API

Dockerfile — API

Base Images

Stage Image Purpose
build mcr.microsoft.com/dotnet/sdk:9.0 Restore + publish
final mcr.microsoft.com/dotnet/aspnet:9.0 Lean runtime image

Multi-Stage Build Pattern

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

COPY ["src/CleanArcStack.API/CleanArcStack.API.csproj", "src/CleanArcStack.API/"]
COPY ["src/CleanArcStack.Application/CleanArcStack.Application.csproj", "src/CleanArcStack.Application/"]
COPY ["src/CleanArcStack.Domain/CleanArcStack.Domain.csproj", "src/CleanArcStack.Domain/"]
COPY ["src/CleanArcStack.Infrastructure/CleanArcStack.Infrastructure.csproj", "src/CleanArcStack.Infrastructure/"]

RUN dotnet restore "src/CleanArcStack.API/CleanArcStack.API.csproj"

COPY . .

RUN dotnet publish "src/CleanArcStack.API/CleanArcStack.API.csproj" \
    -c Release -o /app/publish /p:UseAppHost=false

FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app

RUN apt-get update && apt-get install -y --no-install-recommends curl \
    && rm -rf /var/lib/apt/lists/*

EXPOSE 8080

COPY --from=build /app/publish .

ENTRYPOINT ["dotnet", "DaycareTracking.API.dll"]

Rules

  • Always use multi-stage builds.
  • Copy .csproj files before full source to maximise restore-layer cache hits.
  • Use UseAppHost=false inside containers.
  • Install only runtime-essential packages and clean apt metadata in the same layer.
  • The API listens on HTTP only; TLS is terminated by Nginx / platform ingress.
  • EXPOSE 8080 is documentation, not the deployment contract.
  • Runtime port binding is configured from docker-compose.yml with ASPNETCORE_HTTP_PORTS=8080.

docker-compose.yml — Service Set

Service Overview

Service Image Role
postgres postgres:16-alpine Primary relational database
redis redis:7-alpine Cache / coordination
rabbitmq rabbitmq:3-management-alpine Message broker
elasticsearch elasticsearch:8.12.0 Search + log sink
kibana kibana:8.12.0 Log dashboard + APM UI
kibana-setup elasticsearch:8.12.0 One-shot: sets kibana_system password + installs APM Fleet integration
apm-server elastic/apm-server:8.12.0 Elastic APM Server — receives OTLP traces from the API
minio minio/minio S3-compatible object storage
minio-setup minio/mc One-shot: creates required buckets
api built from Dockerfile ASP.NET Core 9 backend
nginx nginx:alpine Reverse proxy for API + MinIO CDN

Startup Order (expected dependency graph)

postgres ──┐
redis    ──┤
rabbitmq ──┤
minio    ──┼──────────────────────────────► api
           │                               ▲
elasticsearch ──► kibana-setup ──► kibana  │
                       │                  │
                       └──► apm-server ───┘

api   ──┐
minio ──┴──────────────► nginx

Rules:

  • Use depends_on with service_healthy or service_completed_successfully.
  • api should not start before its required backing services are healthy.
  • apm-server depends on kibana-setup: service_completed_successfully — the APM Fleet integration package must be installed in Kibana before the APM Server starts; otherwise it fails with a precondition error.
  • api depends on apm-server: service_healthy.
  • nginx should wait for both api and minio when it fronts both routes.

Secrets — Core Pattern

No plaintext passwords or keys in docker-compose.yml or .env. Every sensitive value lives in .secrets/ and is injected at runtime.

Supported Injection Patterns

1. Images with _FILE support

environment:
  POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
secrets:
  - postgres_password

2. Images without _FILE support

secrets:
  - rabbitmq_password
entrypoint:
  - /bin/sh
  - -ec
  - |
    export RABBITMQ_DEFAULT_PASS="$$(cat /run/secrets/rabbitmq_password)";
    exec docker-entrypoint.sh rabbitmq-server

In inline Compose shell scripts, use $$ so Docker Compose passes a literal $ to the shell.

3. ASP.NET Core API — AddKeyPerFile

Secret files mounted under /run/secrets are mapped automatically:

secrets:
  - Jwt__Secret
  - ConnectionStrings__DefaultConnection
  - RabbitMQ__Password
  - Elasticsearch__Password
  - MinIO__AccessKey
  - MinIO__SecretKey
  - Telemetry__ApmSecretToken   # maps to Telemetry:ApmSecretToken → TelemetrySettings

Secret file names use __ as the hierarchy separator — Telemetry__ApmSecretToken maps to the Telemetry:ApmSecretToken config key, which binds to TelemetrySettings.ApmSecretToken. Using Apm__SecretToken would map to Apm:SecretToken — a different config key — and the token would never reach TelemetrySettings. Always match the file name to the exact config path.

Secrets Definition Block

Each secret should resolve from .secrets/<name> by default, with optional .env overrides for CI/CD paths.

secrets:
  postgres_password:
    file: ${POSTGRES_PASSWORD_FILE:-./.secrets/postgres_password}
  elastic_password:
    file: ${ELASTIC_PASSWORD_FILE:-./.secrets/Elasticsearch__Password}
  minio_access_key:
    file: ${MINIO_ACCESS_KEY_FILE:-./.secrets/MinIO__AccessKey}
  minio_secret_key:
    file: ${MINIO_SECRET_KEY_FILE:-./.secrets/MinIO__SecretKey}

.secrets/ Folder Layout

.secrets/
├── postgres_password
├── RabbitMQ__Password
├── Elasticsearch__Password
├── MinIO__AccessKey
├── MinIO__SecretKey
├── kibana_system_password
├── kibana_encryption_key
├── kibana_security_encryption_key
├── kibana_reporting_encryption_key
├── ConnectionStrings__DefaultConnection
├── Jwt__Secret
├── Apm__SecretToken               # used by apm-server container (shell export)
└── Telemetry__ApmSecretToken      # used by api container (AddKeyPerFile)

Apm__SecretToken and Telemetry__ApmSecretToken must contain the same value — one is read by the APM Server container via shell export, the other by the .NET API via AddKeyPerFile.

Create secret files without a trailing newline: echo -n "value" > .secrets/SecretName


Healthchecks

Every long-running container should declare a healthcheck.

Service Check
postgres pg_isready
redis redis-cli ping
rabbitmq rabbitmq-diagnostics ping
elasticsearch authenticated curl to cluster health
kibana curl status endpoint and verify available state
apm-server TCP port check: bash -c 'echo > /dev/tcp/localhost/8200'
minio curl http://localhost:9000/minio/health/live
api curl http://localhost:8080/health/live
nginx curl http://localhost/health

Example Elasticsearch healthcheck:

test: ["CMD-SHELL", "curl -sf -u elastic:$$(cat /run/secrets/elastic_password) 'http://localhost:9200/_cluster/health?wait_for_status=yellow&timeout=5s' || exit 1"]

Kibana Init Container Pattern

kibana-setup runs once and prepares the kibana_system password before kibana starts.

Rules:

  • restart: "no"
  • kibana depends on kibana-setup with service_completed_successfully
  • rerunning on docker compose up is acceptable; the password-setting flow is idempotent

APM Server Init Container Pattern

APM Server 8.x Fleet-managed mode requires the APM integration package to be installed in Kibana before starting. Without it, APM Server exits with:

precondition 'apm integration installed' failed

kibana-setup handles this by calling the Fleet API after setting the kibana_system password:

kibana-setup:
  # ... sets kibana_system password, then:
  entrypoint:
    - /bin/sh
    - -ec
    - |
      # ... password setup ...

      # Wait for Kibana to be fully available
      until curl -sf -u "elastic:${ELASTIC_PASSWORD}" \
        "http://kibana:5601/api/status" | grep -q '"level":"available"'; do
        echo "Waiting for Kibana..."; sleep 5;
      done

      # Install APM Fleet integration package (idempotent)
      curl -sf -X POST "http://kibana:5601/api/fleet/setup" \
        -H "kbn-xsrf: true" -u "elastic:${ELASTIC_PASSWORD}" || true

      curl -sf -X POST "http://kibana:5601/api/fleet/epm/packages/apm" \
        -H "kbn-xsrf: true" -H "Content-Type: application/json" \
        -u "elastic:${ELASTIC_PASSWORD}" \
        -d '{"force":true}' || true

Then apm-server declares:

depends_on:
  kibana-setup:
    condition: service_completed_successfully

OpenTelemetry / APM Server

OTLP Endpoint — Critical Rule

The OTel .NET SDK (OtlpExporterOptions.Endpoint) uses the URL as-is — it does not append /v1/traces automatically. The full signal path must be explicit in code:

// ✅ Correct — path is explicit
otlp.Endpoint = new Uri($"{baseUrl}/v1/traces");

// ❌ Wrong — sends to root / which is the health endpoint, traces are silently dropped
otlp.Endpoint = new Uri("http://apm-server:8200");

APM Server Environment Variables

Non-sensitive APM config goes in environment:, the secret token comes from a secret file:

api:
  environment:
    - Telemetry__ApmServerUrl=http://apm-server:8200   # base URL — code appends /v1/traces
    - Telemetry__ServiceName=DaycareTracking.API
  secrets:
    - Telemetry__ApmSecretToken   # Bearer token for OTLP authentication

APM Server Secret Token Injection

APM Server reads its secret token via shell export inside the entrypoint:

apm-server:
  secrets:
    - apm_secret_token
  entrypoint:
    - /bin/sh
    - -ec
    - |
      APM_TOKEN="$$(cat /run/secrets/apm_secret_token)";
      exec /usr/share/apm-server/apm-server \
        -E output.elasticsearch.hosts='["elasticsearch:9200"]' \
        -E "apm-server.secret_token=$${APM_TOKEN}"

The API sends the same token in the Authorization: Bearer header via otlp.Headers.


Environment Variables vs Secrets

Value type Where it goes
Non-sensitive config (host, port, DB name, usernames) environment: or .env
Passwords, signing keys, connection strings, access keys .secrets/<file> → Docker secret

Never put secrets in .env, even if the file is gitignored.


.env vs .env.example

File In Git Purpose
.env.example ✅ Yes documents required non-sensitive vars and secret file path overrides
.env ❌ No actual local values for non-sensitive config and secret file paths only

.env may contain:

  • non-sensitive config (POSTGRES_DB, RABBITMQ_USERNAME, MINIO_ENDPOINT)
  • secret file paths (MINIO_SECRET_KEY_FILE=./.secrets/MinIO__SecretKey)

.env must not contain plaintext passwords or tokens.


Adding or Changing Services — Checklist

  1. Add the service to docker-compose.yml.
  2. Add a healthcheck.
  3. If the service has secrets:
    • create .secrets/<name>
    • add the secret under the Compose secrets: block
    • wire it through _FILE, entrypoint export, or AddKeyPerFile
    • when using AddKeyPerFile, ensure the file name matches the exact config key path (double-underscore = colon separator)
    • document any override path in .env.example
  4. Add depends_on relationships for healthy startup order.
  5. If a service requires a one-shot init container (e.g. Kibana setup, APM Fleet package install), use restart: "no" and condition: service_completed_successfully.
  6. If the API consumes the service, register the corresponding health check in Infrastructure.
  7. If the service changes external routing, update nginx/nginx.conf as well.

Backend-Only Rules

  • Keep Docker focused on the API and its infrastructure dependencies.
  • Do not introduce React SPA volumes, frontend build stages, or Nginx static hosting rules yet.
  • Nginx should proxy the API and front MinIO CDN traffic only.

Useful Commands

# Start everything
docker compose up -d

# Rebuild and restart only the API
docker compose up -d --build api

# Restart nginx after config changes
docker compose restart nginx

# Follow API logs
docker compose logs -f api

# Follow APM server logs
docker compose logs -f apm-server

# Stop containers
docker compose down

# Full reset with volumes
docker compose down -v

# Generate a strong secret file without trailing newline
openssl rand -base64 32 | tr -d '\n' > .secrets/<SecretName>

# Create two secrets with the same value (e.g. APM token)
echo -n "my-secret-token" > .secrets/Apm__SecretToken
echo -n "my-secret-token" > .secrets/Telemetry__ApmSecretToken