dotnet-starter-kit Docker & Self-Hosted Deployment

GitHub

Production Grade Cloud-Ready .NET 10 Starter Kit (Web API + React Client) with Multitenancy Support, and Clean/Modular Architecture that saves roughly 200+...

RAW Compose
1. Pull Image (DOCKERHUB) docker pull library/postgres
2. Docker Run (1-Click Launch) docker run -d --name dotnet-starter-kit -p 9000:9000 --restart always postgres:18-alpine
3. Podman Run podman run -d --name dotnet-starter-kit -p 9000:9000 --restart always postgres:18-alpine
4. Skopeo Inspect (Zero-Pull Manifest) skopeo inspect docker://docker.io/library/postgres:18-alpine
5. ORAS Pull (OCI Artifact) oras pull postgres:18-alpine
6. Compose Deployment docker compose up -d
deploy/docker/docker-compose.yml
Download .yml
# 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: