18 KiB
title, last-updated, tags
| title | last-updated | tags | |||
|---|---|---|---|---|---|
| Docker deployment, configuration, and troubleshooting | 2026-08-31 |
|
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:
- 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_KEYin .env.dev) - SeaweedFS Filer UI: http://localhost:8888
- SeaweedFS Master UI: http://localhost:9333
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
frontendpublishes a port to the host, bound to127.0.0.1so a reverse proxy can sit in front of it;sqlserver,api.coreandseaweedfsare reachable only from other containers onprodnet(namedbiergarten-prodnetso 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 anupor 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 restartsnuget-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; survivesdown -vseaweedfsdata-prod- SeaweedFS object storage, external; survivesdown -v
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 networktestnet- 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"]