Files
the-biergarten-app/docs/web/docker.md

18 KiB

title, last-updated, tags
title last-updated tags
Docker deployment, configuration, and troubleshooting 2026-08-31
docker
docker-compose
deployment

This document covers Docker deployment, configuration, and troubleshooting for The Biergarten App.

Overview

The project uses Docker Compose to orchestrate multiple services:

  • SQL Server 2022 database
  • Database migrations runner (DbUp)
  • Database seeder
  • .NET API
  • React Router website (SSR frontend)
  • Test runners

See the deployment diagram for visual representation.

Docker Compose environments

1. Development (docker-compose.dev.yaml)

Purpose: Local development with persistent data

Features:

  • Persistent SQL Server volume
  • NuGet package cache volume (speeds up rebuilds)
  • Swagger UI enabled
  • Seed data included
  • Local Mailpit SMTP server for dev email testing
  • Local SeaweedFS S3-compatible object storage for photo uploads
  • CLEAR_DATABASE=true (drops and recreates schema)

Services:

sqlserver           # SQL Server 2022 (port 1434)
database.migrations # DbUp migrations
database.seed      # Seed initial data
api.core           # Web API (ports 8080, 8081)
frontend           # React Router website (port 3000)
mailpit            # Local dev SMTP server + UI (port 8025)
seaweedfs           # S3-compatible object storage (ports 8333, 8888, 9333, 23646)

Start Development Environment:

docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d

Access:

Stop Environment:

# Stop services (keep volumes)
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down

# Stop and remove volumes (fresh start)
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v

2. Testing (docker-compose.test.yaml)

Purpose: Automated CI/CD testing in an isolated environment

Features:

  • Fresh database each run
  • All test suites execute in parallel
  • Test results exported to ./test-results/
  • Containers auto-exit after completion
  • Fully isolated testnet network

Services:

sqlserver           # Test database
database.migrations # Fresh schema
database.seed      # Test data
api.specs          # Reqnroll BDD tests
unit.tests         # All Features.*.Tests and Shared.*.Tests unit test projects
frontend.tests     # Storybook Vitest + Playwright suites (own Playwright image)

Run Tests:

# Run all tests
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up -d
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml wait api.specs unit.tests frontend.tests

# View results
ls -la test-results/
cat test-results/api-specs/results.trx
cat test-results/Features.Users.Tests.trx

# Clean up
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v

wait blocks until api.specs, unit.tests, and frontend.tests have all stopped, regardless of which one finishes first; up --abort-on-container-exit isn't used here because the one-shot database.migrations/database.seed jobs and frontend.tests (no database dependency) can exit before the other test containers, which would end the run prematurely. See Testing for details.

3. Production (docker-compose.prod.yaml)

Purpose: Production-ready deployment

Features:

  • Production logging levels
  • No database clearing
  • Optimized build configurations
  • Health checks on every long-running service
  • Restart policies that survive a reboot (see Restart tolerance)
  • Security hardening
  • Only frontend publishes a port to the host, bound to 127.0.0.1 so a reverse proxy can sit in front of it; sqlserver, api.core and seaweedfs are reachable only from other containers on prodnet (named biergarten-prodnet so the seed job can join it from its own Compose project)
  • Seeding is a separate, explicitly run compose file (docker-compose.prod.seed.yaml), so an up or a restart leaves the existing catalogue as it is. See Production data below.

Services:

sqlserver         # Production SQL Server (internal only)
api.core          # Production API (internal only, no published ports)
seaweedfs         # S3-compatible object storage (internal only)
frontend          # Production website (127.0.0.1:5656, the only host binding)

Migrating and seeding are run-once jobs kept outside this file, so an up or a restart touches neither the schema nor the catalogue. Run them explicitly at deploy time: see Migrating production and Seeding production.

Deploy Production:

# One time, before the first `up`: the data volumes are external, so Compose
# expects them to exist already.
docker volume create biergartendata-prod
docker volume create seaweedfsdata-prod

docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d

Docker seeds each volume with the ownership the image expects and labels it for SELinux, so the host needs no chown and no :z/:Z mount options on enforcing distributions such as RHEL, AlmaLinux and Fedora.

Migrating production

database.migrations is a run-once job rather than a service in the prod stack, so the schema is migrated when an operator asks for it instead of on every boot. Build the image and run it against the network the prod stack creates:

docker build -t database.migrations \
  -f web/backend/Database/Database.Migrations/Dockerfile \
  --build-arg BUILD_CONFIGURATION=Release --build-arg APP_UID=1000 \
  web/backend

docker run --rm --env-file web/.env.prod --network biergarten-prodnet \
  -e DOTNET_RUNNING_IN_CONTAINER=true database.migrations

DbUp records every script it has applied, so a re-run applies only what is new. Run this with sqlserver already up and healthy, and before the seed job.

Restart tolerance

sqlserver, api.core, seaweedfs and frontend run under restart: unless-stopped, so the stack returns on its own after a container crash, a Docker daemon restart or a host reboot, provided Docker itself starts at boot (systemctl enable docker). The migration and seed jobs stay out of the stack, so a reboot replays neither.

The daemon replays restart policies without replaying the depends_on graph, so after a reboot the containers come back in arbitrary order and each one tolerates a dependency that arrives late. api.core opens its database and S3 connections per request rather than at startup, and frontend answers with its own error states while api.core is still coming up.

Health checks report when the stack has converged:

Service Probe
sqlserver sqlcmd -Q 'SELECT 1', with a 30s start period and 12 retries
api.core GET /health spoken over bash's /dev/tcp — the .NET runtime image carries bash and no HTTP client
seaweedfs curl http://localhost:8333/status, the S3 gateway, which serves once the master, volume and filer servers are up
frontend wget --spider http://127.0.0.1:5656/healthz, answered by Express alone and so independent of the API

sqlserver also carries stop_grace_period: 60s, giving a checkpoint time to finish on shutdown instead of being recovered on the next start.

Deploy and wait for the stack to report healthy, or check convergence after a reboot:

docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d --wait
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml ps

Seeding production (docker-compose.prod.seed.yaml)

database.seed lives in its own compose file, run on demand once the prod stack is up, sqlserver is healthy, and the schema is migrated:

docker compose --env-file web/.env.prod -f web/docker-compose.prod.seed.yaml \
  run --rm --build database.seed

The job runs as the biergarten-prod-seed Compose project and attaches to the external biergarten-prodnet network, which the main prod stack creates, so it reaches sqlserver and seaweedfs under the same hostnames the API uses. It exits when seeding finishes; --rm removes the container. Bring the prod stack up first, because the seed project has no visibility into the other project's health checks, so a run started before SQL Server is accepting connections fails.

Production data

A production deployment seeded this way serves the AI-generated fixture data produced by the pipeline. Per Pipeline ethics, bias, and known issues, anyone interacting with an application seeded from it has to be told upfront that the content is AI-generated.

The website meets that obligation with AiDisclosureDialog (web/frontend/app/features/home/components/AiDisclosureDialog.tsx), which the root route renders on every full page load, whichever route the visitor lands on. It is part of the server-rendered HTML rather than mounted after hydration, so the disclosure is on screen with the first paint. Acknowledging it hides the dialog for the remainder of that page load; a new load shows it again.

Keep the seed job and the dialog together: any deployment that runs docker-compose.prod.seed.yaml needs the disclosure in place to satisfy the ethics documentation.

4. Database only (docker-compose.db.yaml)

Purpose: Run just the database for local backend development outside Docker (for example, dotnet run against a containerized SQL Server)

Services:

sqlserver           # SQL Server 2022 (port 1434)
database.migrations # DbUp migrations
database.seed      # Seed initial data

No api.core or mailpit container. Uses .env.dev, same as development.

Start:

docker compose --env-file web/.env.dev -f web/docker-compose.db.yaml up -d

5. Minimal (docker-compose.min.yaml)

Purpose: Local dev where the API runs outside Docker but the database and mail capture still run in containers

Services:

sqlserver           # SQL Server 2022 (port 1433)
database.migrations # DbUp migrations
database.seed      # Seed initial data
mailpit            # Local dev SMTP server + UI (port 8025)

No api.core container. Uses .env.local instead of .env.dev, and exposes SQL Server on the standard port 1433 (docker-compose.db.yaml and docker-compose.dev.yaml both use 1434).

Start:

docker compose --env-file web/.env.local -f web/docker-compose.min.yaml up -d

Service dependencies

Docker Compose manages startup order using health checks:

graph TD
    A[sqlserver: health check passes] --> B[database.migrations: completes successfully]
    B --> C[database.seed: completes successfully]
    C --> D[api.core / tests: start when ready]
    E[seaweedfs: container started] --> D

In development, api.core also depends on seaweedfs (condition: service_started, not a health check) alongside database.seed.

Production has a shorter chain, because the migration and seed jobs run outside the stack: api.core waits for seaweedfs to pass its health check, and frontend waits for api.core to report healthy. This ordering applies to docker compose up; see Restart tolerance for what happens on a host reboot.

Health Check Example (SQL Server):

healthcheck:
  test:
    [
      "CMD-SHELL",
      "sqlcmd -S localhost -U sa -P '${DB_PASSWORD}' -C -Q 'SELECT 1'",
    ]
  interval: 10s
  timeout: 5s
  retries: 12
  start_period: 30s

Dependency Configuration:

api.core:
  depends_on:
    database.seed:
      condition: service_completed_successfully

Volumes

Persistent volumes

Development:

  • sqlserverdata-dev - Database files persist between restarts
  • nuget-cache-dev - NuGet package cache (speeds up builds)
  • seaweedfsdata-dev - SeaweedFS object storage data persists between restarts

Testing:

  • sqlserverdata-test - Temporary, typically removed after tests

Production:

  • biergartendata-prod - Database files, external; survives down -v
  • seaweedfsdata-prod - SeaweedFS object storage, external; survives down -v

See Production data volumes.

Mounted volumes

Test Results:

volumes:
  - ./test-results:/app/test-results

Test results are written to host filesystem for CI/CD integration.

Production data volumes

biergartendata-prod and seaweedfsdata-prod are declared external: true. Compose removes only the volumes it owns, so docker compose down -v tears the stack down and leaves the database and the uploaded photos in place. Deleting them takes an explicit docker volume rm.

The trade-off is that Compose creates neither one, and up fails with a clear error until both exist, hence the docker volume create step in Deploy Production. External volumes also carry their literal name rather than a <project>_ prefix, so docker volume ls shows biergartendata-prod and seaweedfsdata-prod unprefixed, unlike the web_-prefixed volumes of the dev and test stacks.

Both live under /var/lib/docker/volumes. To put them on a specific disk, point the daemon's data-root at it in /etc/docker/daemon.json, or mount the disk at /var/lib/docker.

Back up the database through SQL Server rather than by copying its files, since a file copy taken while the engine is running can be torn:

docker exec prod-env-sqlserver /opt/mssql-tools18/bin/sqlcmd \
  -S localhost -U sa -P '<db-password>' -C \
  -Q "BACKUP DATABASE [<db-name>] TO DISK = '/var/opt/mssql/data/backup.bak'"
docker cp prod-env-sqlserver:/var/opt/mssql/data/backup.bak .

Object storage can be archived directly, with the stack stopped:

docker run --rm -v seaweedfsdata-prod:/data -v "$PWD:/backup" \
  alpine tar czf /backup/seaweedfs.tar.gz -C /data .

Networks

Each environment uses isolated bridge networks:

  • devnet - Development network
  • testnet - Testing network (fully isolated)
  • prodnet - Production network

Environment variables

All containers are configured via environment variables from .env files:

env_file: ".env.dev" # or .env.test, .env.prod

environment:
  ASPNETCORE_ENVIRONMENT: "Development"
  DOTNET_RUNNING_IN_CONTAINER: "true"
  DB_SERVER: "${DB_SERVER}"
  DB_NAME: "${DB_NAME}"
  DB_USER: "${DB_USER}"
  DB_PASSWORD: "${DB_PASSWORD}"
  ACCESS_TOKEN_SECRET: "${ACCESS_TOKEN_SECRET}"
  REFRESH_TOKEN_SECRET: "${REFRESH_TOKEN_SECRET}"
  CONFIRMATION_TOKEN_SECRET: "${CONFIRMATION_TOKEN_SECRET}"
  SEAWEEDFS_SERVICE_URL: "${SEAWEEDFS_SERVICE_URL}"
  SEAWEEDFS_ACCESS_KEY_ID: "${SEAWEEDFS_ACCESS_KEY_ID}"
  SEAWEEDFS_SECRET_ACCESS_KEY: "${SEAWEEDFS_SECRET_ACCESS_KEY}"
  SEAWEEDFS_BUCKET: "${SEAWEEDFS_BUCKET}"

For complete list, see Environment Variables.

Common commands

View services

# Running services
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml ps

# All containers (including stopped)
docker ps -a

View logs

# All services
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f

# Specific service
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs -f api.core

# Last 100 lines
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml logs --tail=100 api.core

Execute commands in container

# Interactive shell
docker exec -it dev-env-api-core bash

# Run command (replace <db-password> with the value of DB_PASSWORD from .env.dev)
docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '<db-password>' -C

Restart services

# Restart all services
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart

# Restart specific service
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml restart api.core

# Rebuild and restart
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d --build api.core

Build images

# Build all images
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build

# Build specific service
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build api.core

# Build without cache
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml build --no-cache

Clean up

# Stop and remove containers
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down

# Remove containers and volumes
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v

# Remove containers, volumes, and images
docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml down -v --rmi all

# System-wide cleanup
docker system prune -af --volumes

Dockerfile structure

Multi-stage build

# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY ["Project/Project.csproj", "Project/"]
RUN dotnet restore
COPY . .
RUN dotnet build -c Release

# Stage 2: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
WORKDIR /app
COPY --from=build /app/build .
ENTRYPOINT ["dotnet", "Project.dll"]

Additional resources