Production Grade Cloud-Ready .NET 10 Starter Kit (Web API + React Client) with Multitenancy Support, and Clean/Modular Architecture that saves roughly 200+...
docker pull library/postgres
docker run -d --name dotnet-starter-kit -p 9000:9000 --restart always postgres:18-alpine
podman run -d --name dotnet-starter-kit -p 9000:9000 --restart always postgres:18-alpine
skopeo inspect docker://docker.io/library/postgres:18-alpine
oras pull postgres:18-alpine
docker compose up -d
# fullstackhero โ production docker-compose.
# Run from this directory:
# cp .env.example .env && $EDITOR .env
# docker compose up -d --build
#
# Operator owns the edge (TLS / subdomain routing). This file publishes
# the FSH services and the RustFS S3 API on host ports (browsers upload
# straight to presigned S3 URLs); postgres/redis stay compose-internal
# unless you uncomment their ports blocks.
# The Mailpit inbox UI is published too, bound to the host loopback only.
name: fsh
services:
postgres:
image: postgres:18-alpine
container_name: fsh-postgres
restart: unless-stopped
environment:
POSTGRES_DB: fsh
POSTGRES_USER: fsh
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}
volumes:
# The 18+ images keep PGDATA in a versioned subdirectory (/var/lib/postgresql/18/docker) and
# refuse to start with a mount at the old /var/lib/postgresql/data, even on an empty volume.
- pg_data:/var/lib/postgresql
- ./postgres-init:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U fsh -d fsh"]
interval: 5s
timeout: 3s
retries: 12
# Uncomment to expose for host psql / pg_dump access:
# ports:
# - "5432:5432"
redis:
image: valkey/valkey:9.1.0-alpine
container_name: fsh-redis
restart: unless-stopped
command:
- valkey-server
- --requirepass
- ${REDIS_PASSWORD:?REDIS_PASSWORD is required}
- --appendonly
- "yes"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD-SHELL", "valkey-cli -a $$REDIS_PASSWORD PING | grep PONG"]
interval: 5s
timeout: 3s
retries: 12
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
# Uncomment to expose for host redis-cli:
# ports:
# - "6379:6379"
rustfs:
image: rustfs/rustfs:1.0.0
container_name: fsh-rustfs
restart: unless-stopped
environment:
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:?RUSTFS_ACCESS_KEY is required}
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:?RUSTFS_SECRET_KEY is required}
RUSTFS_CONSOLE_ENABLE: "true"
# The admin and dashboard PUT uploads cross-origin to presigned URLs on this store.
RUSTFS_CORS_ALLOWED_ORIGINS: ${FSH_ADMIN_URL},${FSH_DASHBOARD_URL}
volumes:
- rustfs_data:/data
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:9000/health"]
interval: 10s
timeout: 3s
retries: 12
# The S3 API is published because browsers reach it directly through the
# presigned URLs the API hands out (FSH_S3_PUBLIC_URL). This binds all
# interfaces: firewall the port, or when your proxy runs on this host bind
# it to loopback instead ("127.0.0.1:${FSH_S3_PORT:-9000}:9000") so only
# the proxy can reach it. Uncomment the console (admin UI) line to expose it too:
ports:
- "${FSH_S3_PORT:-9000}:9000" # S3 API
# - "9001:9001" # Web console
# One-shot: create the bucket the Files module writes to and open public/*
# to anonymous reads. The app never auto-creates the bucket (S3StorageService
# just PutObjects into Storage:S3:Bucket), so without this the first upload
# fails with NoSuchBucket. Visibility lives in the object key: public files
# go under public/ and are served straight from ${FSH_S3_PUBLIC_URL}/fsh/...;
# everything else (private/, and keys from before public/ existed) needs a
# presigned URL. Re-applied on every `up`, so it also narrows older policies.
rustfs-init:
image: amazon/aws-cli:2.37.3
container_name: fsh-rustfs-init
restart: "no"
depends_on:
rustfs: { condition: service_healthy }
environment:
AWS_ACCESS_KEY_ID: ${RUSTFS_ACCESS_KEY}
AWS_SECRET_ACCESS_KEY: ${RUSTFS_SECRET_KEY}
AWS_DEFAULT_REGION: us-east-1
entrypoint: /bin/sh
command:
- -c
- |
set -e
aws --endpoint-url http://rustfs:9000 s3api head-bucket --bucket fsh 2>/dev/null ||
aws --endpoint-url http://rustfs:9000 s3api create-bucket --bucket fsh
aws --endpoint-url http://rustfs:9000 s3api put-bucket-policy --bucket fsh --policy '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::fsh/public/*"]}]}'
# Local mail catcher, started only with the mail-catcher profile
# (COMPOSE_PROFILES in .env). SMTP (1025) stays on the compose network.
mailpit:
image: axllent/mailpit:v1.31.3
container_name: fsh-mailpit
restart: unless-stopped
profiles: [mail-catcher]
ports:
# Loopback only: the inbox holds live password-reset and confirmation links.
- "127.0.0.1:${FSH_MAILPIT_PORT:-8025}:8025"
migrator:
build:
context: ../..
dockerfile: src/Host/FSH.Starter.DbMigrator/Dockerfile
image: fsh/dbmigrator:local
container_name: fsh-migrator
restart: "no"
depends_on:
postgres: { condition: service_healthy }
environment:
DOTNET_ENVIRONMENT: Production
# The chiseled image has no libgssapi_krb5; Npgsql's default GSS Prefer logs a load failure per start.
DatabaseOptions__ConnectionString: Host=postgres;Database=fsh;Username=fsh;Password=${POSTGRES_PASSWORD};GSS Encryption Mode=Disable
CachingOptions__Redis: redis,password=${REDIS_PASSWORD}
JwtOptions__SigningKey: ${JWT_SIGNING_KEY:?JWT_SIGNING_KEY is required}
Seed__DefaultAdminPassword: ${SEED_ADMIN_PASSWORD:?SEED_ADMIN_PASSWORD is required}
command: ["apply", "--seed"]
api:
build:
context: ../..
dockerfile: src/Host/FSH.Starter.Api/Dockerfile
image: fsh/api:local
container_name: fsh-api
restart: unless-stopped
depends_on:
postgres: { condition: service_healthy }
redis: { condition: service_healthy }
rustfs: { condition: service_healthy }
rustfs-init: { condition: service_completed_successfully }
migrator: { condition: service_completed_successfully }
environment:
DOTNET_ENVIRONMENT: Production
ASPNETCORE_URLS: http://+:8080
# Same chiseled-image reason as the migrator: no libgssapi_krb5, so skip the GSS attempt.
DatabaseOptions__ConnectionString: Host=postgres;Database=fsh;Username=fsh;Password=${POSTGRES_PASSWORD};GSS Encryption Mode=Disable
CachingOptions__Redis: redis,password=${REDIS_PASSWORD}
JwtOptions__SigningKey: ${JWT_SIGNING_KEY}
Seed__DefaultAdminPassword: ${SEED_ADMIN_PASSWORD}
Storage__Provider: s3
Storage__S3__ServiceUrl: http://rustfs:9000
# Presigned upload/download URLs go to browsers, which cannot resolve rustfs:9000.
Storage__S3__PresignServiceUrl: ${FSH_S3_PUBLIC_URL:?FSH_S3_PUBLIC_URL is required}
# Durable URLs for public files (avatars, product images): path-style
# <public S3 URL>/<bucket>/public/..., readable through the public/* policy.
Storage__S3__PublicBaseUrl: ${FSH_S3_PUBLIC_URL}/fsh
Storage__S3__AccessKey: ${RUSTFS_ACCESS_KEY}
Storage__S3__SecretKey: ${RUSTFS_SECRET_KEY}
Storage__S3__Bucket: fsh
Storage__S3__ForcePathStyle: "true"
# Defaults target the bundled Mailpit (plain SMTP, no STARTTLS); a real provider is set in .env only.
MailOptions__Smtp__Host: ${FSH_SMTP_HOST:-mailpit}
MailOptions__Smtp__Port: ${FSH_SMTP_PORT:-1025}
MailOptions__Smtp__Security: ${FSH_SMTP_SECURITY:-None}
MailOptions__Smtp__UserName: ${FSH_SMTP_USERNAME:-}
MailOptions__Smtp__Password: ${FSH_SMTP_PASSWORD:-}
# appsettings.Production.json leaves the sender blank, and a real provider rejects an empty MAIL FROM.
MailOptions__From: ${FSH_MAIL_FROM:[email protected]}
AllowedHosts: "*"
HangfireOptions__UserName: ${HANGFIRE_USERNAME:?HANGFIRE_USERNAME is required}
HangfireOptions__Password: ${HANGFIRE_PASSWORD:?HANGFIRE_PASSWORD is required}
CorsOptions__AllowedOrigins__0: ${FSH_ADMIN_URL:?FSH_ADMIN_URL is required}
CorsOptions__AllowedOrigins__1: ${FSH_DASHBOARD_URL:?FSH_DASHBOARD_URL is required}
# E-mail-link origins. Separate list from CORS: the API origin must stay off it.
FrontendOptions__AllowedOrigins__0: ${FSH_ADMIN_URL}
FrontendOptions__AllowedOrigins__1: ${FSH_DASHBOARD_URL}
FrontendOptions__DefaultOrigin: ${FSH_DASHBOARD_URL}
OpenTelemetryOptions__Exporter__Otlp__Endpoint: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
ports:
- "${FSH_API_PORT:-8080}:8080"
admin:
build:
context: ../../clients/admin
image: fsh/admin:local
container_name: fsh-admin
restart: unless-stopped
depends_on:
migrator: { condition: service_completed_successfully }
environment:
FSH_API_URL: ${FSH_API_URL:?FSH_API_URL is required}
FSH_DASHBOARD_URL: ${FSH_DASHBOARD_URL}
FSH_DEFAULT_TENANT: ${FSH_DEFAULT_TENANT:-root}
ports:
- "${FSH_ADMIN_PORT:-8081}:80"
dashboard:
build:
context: ../../clients/dashboard
image: fsh/dashboard:local
container_name: fsh-dashboard
restart: unless-stopped
depends_on:
migrator: { condition: service_completed_successfully }
environment:
FSH_API_URL: ${FSH_API_URL}
FSH_DEFAULT_TENANT: ${FSH_DEFAULT_TENANT:-root}
ports:
- "${FSH_DASHBOARD_PORT:-8082}:80"
volumes:
pg_data:
redis_data:
rustfs_data: