--- title: Docker deployment, configuration, and troubleshooting last-updated: 2026-08-31 tags: - 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](diagrams/deployment.md) 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**: ```yaml 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**: ```bash docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d ``` **Access**: - Website: http://localhost:3000 - API Swagger: http://localhost:8080/swagger - Health Check: http://localhost:8080/health - Mailpit UI: http://localhost:8025 - SQL Server: localhost:1434 (sa credentials from .env.dev) - SeaweedFS S3 API: http://localhost:8333 (credentials from `SEAWEEDFS_ACCESS_KEY_ID`/`SEAWEEDFS_SECRET_ACCESS_KEY` in .env.dev) - SeaweedFS Filer UI: http://localhost:8888 - SeaweedFS Master UI: http://localhost:9333 **Stop Environment**: ```bash # 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**: ```yaml 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**: ```bash # 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](testing.md#running-tests-with-docker-recommended) 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](#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](#production-data) below. **Services**: ```yaml sqlserver # Production SQL Server (internal only) database.migrations # Schema updates 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) ``` **Deploy Production**: ```bash # 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. #### 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`). `database.migrations` keeps `restart: "no"`: it is a one-shot job that runs once per deploy and stays exited across a reboot, so the schema is migrated on deploy rather than on every boot. 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: ```bash 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 and `sqlserver` is healthy: ```bash 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 — 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](../pipeline/ETHICS-AND-KNOWN-ISSUES.md), 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/disclosure/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**: ```yaml 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**: ```bash 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**: ```yaml 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**: ```bash 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: ```mermaid 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`. In production the chain is gated on health instead: `api.core` waits for `seaweedfs` to pass its health check and for `database.migrations` to exit successfully, and `frontend` waits for `api.core` to report healthy. This ordering applies to `docker compose up`; see [Restart tolerance](#restart-tolerance) for what happens on a host reboot. **Health Check Example** (SQL Server): ```yaml 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**: ```yaml 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` - `nuget-cache-prod` - Production NuGet cache See [Production data volumes](#production-data-volumes). ### Mounted volumes **Test Results**: ```yaml 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](#3-production-docker-composeprodyaml). External volumes also carry their literal name rather than a `_` prefix, which is why `docker volume ls` shows `biergartendata-prod` next to `web_nuget-cache-prod`. 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: ```bash docker exec prod-env-sqlserver /opt/mssql-tools18/bin/sqlcmd \ -S localhost -U sa -P '' -C \ -Q "BACKUP DATABASE [] 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: ```bash 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: ```yaml 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](environment-variables.md). ## Common commands ### View services ```bash # 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 ```bash # 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 ```bash # Interactive shell docker exec -it dev-env-api-core bash # Run command (replace with the value of DB_PASSWORD from .env.dev) docker exec dev-env-sqlserver /opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P '' -C ``` ### Restart services ```bash # 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 ```bash # 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 ```bash # 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 ```dockerfile # 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 - [Docker Compose Documentation](https://docs.docker.com/compose/) - [.NET Docker Images](https://hub.docker.com/_/microsoft-dotnet) - [SQL Server Docker Images](https://hub.docker.com/_/microsoft-mssql-server)