| applyTo | docker-compose.yml,**/Dockerfile,**/*.dockerfile,.env,.env.example |
|---|
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.
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
| Stage | Image | Purpose |
|---|---|---|
build |
mcr.microsoft.com/dotnet/sdk:9.0 |
Restore + publish |
final |
mcr.microsoft.com/dotnet/aspnet:9.0 |
Lean runtime image |
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"]- Always use multi-stage builds.
- Copy
.csprojfiles before full source to maximise restore-layer cache hits. - Use
UseAppHost=falseinside 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 8080is documentation, not the deployment contract.- Runtime port binding is configured from
docker-compose.ymlwithASPNETCORE_HTTP_PORTS=8080.
| 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 |
postgres ──┐
redis ──┤
rabbitmq ──┤
minio ──┼──────────────────────────────► api
│ ▲
elasticsearch ──► kibana-setup ──► kibana │
│ │
└──► apm-server ───┘
api ──┐
minio ──┴──────────────► nginx
Rules:
- Use
depends_onwithservice_healthyorservice_completed_successfully. apishould not start before its required backing services are healthy.apm-serverdepends onkibana-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.apidepends onapm-server: service_healthy.nginxshould wait for bothapiandminiowhen it fronts both routes.
No plaintext passwords or keys in docker-compose.yml or .env.
Every sensitive value lives in .secrets/ and is injected at runtime.
environment:
POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
secrets:
- postgres_passwordsecrets:
- rabbitmq_password
entrypoint:
- /bin/sh
- -ec
- |
export RABBITMQ_DEFAULT_PASS="$$(cat /run/secrets/rabbitmq_password)";
exec docker-entrypoint.sh rabbitmq-serverIn inline Compose shell scripts, use
$$so Docker Compose passes a literal$to the shell.
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 → TelemetrySettingsSecret file names use
__as the hierarchy separator —Telemetry__ApmSecretTokenmaps to theTelemetry:ApmSecretTokenconfig key, which binds toTelemetrySettings.ApmSecretToken. UsingApm__SecretTokenwould map toApm:SecretToken— a different config key — and the token would never reachTelemetrySettings. Always match the file name to the exact config path.
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/
├── 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__SecretTokenandTelemetry__ApmSecretTokenmust contain the same value — one is read by the APM Server container via shell export, the other by the .NET API viaAddKeyPerFile.
Create secret files without a trailing newline:
echo -n "value" > .secrets/SecretName
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-setup runs once and prepares the kibana_system password before kibana starts.
Rules:
restart: "no"kibanadepends onkibana-setupwithservice_completed_successfully- rerunning on
docker compose upis acceptable; the password-setting flow is idempotent
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}' || trueThen apm-server declares:
depends_on:
kibana-setup:
condition: service_completed_successfullyThe 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");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 authenticationAPM 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.
| 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.
| 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.
- Add the service to
docker-compose.yml. - Add a healthcheck.
- If the service has secrets:
- create
.secrets/<name> - add the secret under the Compose
secrets:block - wire it through
_FILE, entrypoint export, orAddKeyPerFile - when using
AddKeyPerFile, ensure the file name matches the exact config key path (double-underscore = colon separator) - document any override path in
.env.example
- create
- Add
depends_onrelationships for healthy startup order. - If a service requires a one-shot init container (e.g. Kibana setup, APM Fleet package install), use
restart: "no"andcondition: service_completed_successfully. - If the API consumes the service, register the corresponding health check in Infrastructure.
- If the service changes external routing, update
nginx/nginx.confas well.
- 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.
# 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