Deployment
Docker
Generate Docker files with the CLI:
firefly gen dockerOr create a Dockerfile manually:
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/MyApp/MyApp.fsproj -c Release -o /app
FROM mcr.microsoft.com/dotnet/aspnet:10.0
WORKDIR /app
COPY --from=build /app .
EXPOSE 8080
ENV ASPNETCORE_ENVIRONMENT=Production
ENTRYPOINT ["dotnet", "MyApp.dll"]Build and run:
docker build -t myapp .
docker run -p 8080:8080 --env-file .env.production myappProduction Configuration
Bind to All Interfaces
let config =
App.defaults
|> App.host "0.0.0.0"
|> App.port 8080Graceful Shutdown
Configure a shutdown timeout so in-flight requests can complete:
App.defaults
|> App.shutdownTimeout (TimeSpan.FromSeconds 30.0)The server waits up to the specified duration for active requests to finish before forcing shutdown.
Error Handling
Always configure a global error handler in production:
App.defaults
|> App.onError (fun ex req -> task {
// Log the error (use your logging library)
printfn $"Error: {ex.Message}"
return Response.json {| error = "Internal server error" |} |> Response.status 500
})
|> App.notFound (fun req -> task {
return Response.json {| error = "Not found" |} |> Response.status 404
})Recommended Middleware Stack
let config =
App.defaults
|> App.port 8080
|> App.host "0.0.0.0"
|> App.middleware RequestId.middleware
|> App.middleware CorrelationId.middleware
|> App.middleware Telemetry.middleware
|> App.middleware SecureHeaders.middleware
|> App.middleware Compress.auto
|> App.onError errorHandler
|> App.notFound notFoundHandler
|> App.shutdownTimeout (TimeSpan.FromSeconds 30.0)Health Checks
Register a health endpoint for container orchestrators and load balancers:
let healthHandler =
Health.handler [
Health.ping
Health.check "database" (fun () -> task {
do! db.PingAsync()
})
]
Route.start
|> Route.get "/health" healthHandler
|> Route.get "/healthz" healthHandler // common k8s conventionResponse when healthy (200):
{
"status": "healthy",
"checks": [
{ "name": "ping", "status": "healthy", "duration": "00:00:00.001", "error": null },
{ "name": "database", "status": "healthy", "duration": "00:00:00.015", "error": null }
],
"totalDuration": "00:00:00.016"
}Response when unhealthy (503):
{
"status": "unhealthy",
"checks": [
{ "name": "ping", "status": "healthy", "duration": "00:00:00.001", "error": null },
{ "name": "database", "status": "unhealthy", "duration": "00:00:05.000", "error": "Connection refused" }
],
"totalDuration": "00:00:05.001"
}Environment Variables
Use Env.load for typed configuration. Environment variables always override .env file values, so you can use .env for local development and real env vars in production:
type ProdConfig = {
DatabaseUrl: string
Port: int
JwtSecret: string
CorsOrigins: string option
}
let config = Env.load<ProdConfig>()Set in your container orchestrator:
# docker-compose.yml
services:
app:
image: myapp
environment:
DATABASE_URL: postgres://db:5432/myapp
PORT: "8080"
JWT_SECRET: ${JWT_SECRET}
ports:
- "8080:8080"Observability
OpenTelemetry
Enable tracing and metrics:
App.defaults
|> App.middleware Telemetry.middleware
|> App.services [
Service.raw (fun services ->
services.AddOpenTelemetry()
.WithTracing(fun builder ->
builder
.AddSource(Telemetry.sourceName)
.AddOtlpExporter()
|> ignore
)
.WithMetrics(fun builder ->
builder
.AddMeter(Telemetry.meterName)
.AddOtlpExporter()
|> ignore
)
|> ignore
)
]Request Tracing
Use Request ID and Correlation ID middleware for distributed tracing:
App.defaults
|> App.middleware RequestId.middleware
|> App.middleware CorrelationId.middlewareAccess in handlers:
let handler (req: Request) = task {
let requestId = req.RequestId // string option
let correlationId = req.CorrelationId // string option
return Response.ok
}