# 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/..., 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:-no-reply@fsh.local} 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: