Fly.io has quietly become one of the most developer-friendly platforms for deploying containerized workloads close to your users. It runs your Docker containers on bare-metal servers spread across 35+ regions, connected by a private Anycast network — meaning traffic enters the nearest point-of-presence, not a single datacenter. For .NET APIs, the story is straightforward: build a Docker image, configure a fly.toml file, and push.
What Fly.io Is (and Isn't)
Fly.io is not a PaaS in the Heroku sense. It is closer to a managed container platform that gives you:
- Anycast routing — a single IP routes requests to the nearest healthy instance worldwide.
- Micro VMs (Firecracker) — each container runs inside a lightweight VM, not a shared container namespace.
- Global placement — deploy to one region or many with a single flag.
- Built-in private networking — every app gets a
.internalDNS name on a WireGuard mesh. - Native Postgres and Redis add-ons (managed by Fly, not third parties).
What it is not: a serverless platform, a Kubernetes cluster, or a zero-config buildpack host. You supply the Dockerfile; Fly.io runs it.
Anycast Network and Regions
When you fly deploy, Fly.io places your app in the region you specify (default: iad — Northern Virginia). Traffic to your public IP routes to the closest region that has a healthy instance.
# List all available regions
fly platform regions| Region Code | Location |
|---|---|
| iad | Ashburn, VA (US) |
| ord | Chicago, IL (US) |
| lax | Los Angeles, CA (US) |
| lhr | London, UK |
| fra | Frankfurt, Germany |
| nrt | Tokyo, Japan |
| syd | Sydney, Australia |
| gru | São Paulo, Brazil |
For a .NET API serving a global audience, you can run three instances — iad, lhr, nrt — and Fly routes each user to the nearest one automatically.
Project Setup
Start with a standard .NET Web API generated by the CLI (the config in this guide matches the deployable sample at samples/dotnet-hosting, including its deploy/fly.toml):
dotnet new webapi -n MyApi --use-controllers
cd MyApiThe structure matters less than making sure your app listens on the port Fly.io expects. Fly injects PORT as an environment variable. For HTTP services configured in fly.toml, the default internal port is 8080.
Configure Kestrel to respect that in Program.cs:
var builder = WebApplication.CreateBuilder(args);
// Fly.io sets PORT at runtime; fall back to 8080 for local development
var port = Environment.GetEnvironmentVariable("PORT") ?? "8080";
builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseAuthorization();
app.MapControllers();
app.Run();Multi-Stage Dockerfile
A production-grade Dockerfile for .NET uses multi-stage builds to keep the final image small and runs as a non-root user — both security best practices that Fly.io also recommends.
# syntax=docker/dockerfile:1
# ── Build stage ─────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Copy project file first so Docker cache survives source-only changes
COPY MyApi.csproj ./
RUN dotnet restore
COPY . .
RUN dotnet publish -c Release -o /app/publish \
--no-restore \
/p:UseAppHost=false
# ── Runtime stage ────────────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app
# Create a non-root user; Fly.io micro VMs isolate at the VM level,
# but running non-root is defence-in-depth
RUN addgroup --system appgroup && adduser --system --ingroup appgroup appuser
COPY --from=build /app/publish .
# Switch to non-root before the final CMD
USER appuser
ENV ASPNETCORE_ENVIRONMENT=Production
EXPOSE 8080
ENTRYPOINT ["dotnet", "MyApi.dll"]The mcr.microsoft.com/dotnet/aspnet:10.0 runtime image is ~220 MB. If size matters, switch to mcr.microsoft.com/dotnet/aspnet:10.0-alpine (~100 MB), but note that Alpine uses musl libc, which can affect some native interop scenarios.
Verify the image builds locally before touching Fly:
docker build -t myapi:local .
docker run --rm -p 8080:8080 -e ASPNETCORE_ENVIRONMENT=Development myapi:localInstalling flyctl
flyctl is the single CLI tool for everything on Fly.io.
# macOS / Linux
curl -L https://fly.io/install.sh | sh
# Windows (PowerShell)
iwr https://fly.io/install.ps1 -useb | iex
# Homebrew
brew install flyctlAuthenticate:
fly auth loginThis opens a browser. After login, flyctl stores a token in ~/.fly/config.yml.
fly launch — First Deployment
fly launch is an interactive wizard that detects your Dockerfile, creates the app on Fly.io, writes fly.toml, and optionally deploys immediately.
fly launchYou will be prompted for:
- App name — must be globally unique across all Fly.io customers (e.g.,
myapi-prod). - Primary region — pick the region closest to your users.
- Postgres database — decline here; we set it up separately below.
- Deploy now? — yes.
After the wizard finishes, examine the generated fly.toml.
fly.toml Configuration
fly.toml is the source of truth for your app's deployment config. Here is an annotated production-ready version:
# The app name must match what you created with fly launch
app = "myapi-prod"
# Fly.io builds from the Dockerfile in the current directory by default
[build]
dockerfile = "Dockerfile"
# Primary region — where the first instance lives
primary_region = "iad"
# Environment variables that are NOT secrets
# Secrets (connection strings, API keys) go in fly secrets, not here
[env]
ASPNETCORE_ENVIRONMENT = "Production"
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT = "false"
# HTTP service config; Fly.io terminates TLS at the edge and forwards HTTP
[http_service]
internal_port = 8080 # Must match EXPOSE in your Dockerfile
force_https = true # Redirect HTTP → HTTPS automatically
auto_stop_machines = true # Stop idle machines to save cost
auto_start_machines = true # Start a machine when a request arrives
min_machines_running = 0 # 0 = full scale-to-zero; 1 = always-on
# Health check — Fly marks the machine healthy once this returns 200
[http_service.concurrency]
type = "requests"
hard_limit = 250
soft_limit = 200
[[vm]]
cpu_kind = "shared"
cpus = 1
memory_mb = 256auto_stop_machines = true with min_machines_running = 0 enables scale-to-zero. Your first request after an idle period will experience a cold start (see the Cold Starts section below). Set min_machines_running = 1 for production APIs where latency matters.
Adding a Health Check Endpoint
Fly.io uses the [[http_service.checks]] stanza to determine machine health. Add a dedicated endpoint in your API:
// Minimal health check — no dependencies, just confirms the process is alive
app.MapGet("/health", () => Results.Ok(new { status = "healthy", timestamp = DateTime.UtcNow }))
.WithName("HealthCheck")
.AllowAnonymous();Then reference it in fly.toml:
[http_service]
internal_port = 8080
force_https = true
[[http_service.checks]]
grace_period = "10s" # Time to wait before first check after start
interval = "15s"
method = "GET"
path = "/health"
timeout = "5s"Key flyctl Commands
| Command | What It Does |
|---|---|
fly launch | Interactive first-deploy wizard |
fly deploy | Build image and deploy; uses local Docker daemon by default |
fly deploy --remote-only | Build on Fly's remote builder (no local Docker needed) |
fly status | Show running machines and their health |
fly logs | Tail live logs from all machines |
fly logs -i <machine-id> | Logs from a specific machine |
fly ssh console | Open a shell inside a running machine |
fly scale count 3 | Scale to 3 machines in the primary region |
fly scale count 1 --region lhr | Ensure 1 machine in London |
fly releases | List all deployments with image tags |
fly rollback | Roll back to the previous release |
fly apps destroy myapi-prod | Permanently delete the app |
Deploying After Code Changes
# The standard deploy loop
fly deploy
# Watch the deployment progress
fly status --watch
# Tail logs after deploy
fly logsFly.io performs a rolling deploy by default: it starts new machines, waits for health checks to pass, then removes old machines. Zero-downtime by default.
Secrets Management
Never put credentials in fly.toml or environment variables that end up in source control. Use fly secrets:
# Set a secret (encrypted at rest, injected as env var at runtime)
fly secrets set DATABASE_URL="postgresql://user:pass@hostname/db"
# Set multiple at once
fly secrets set \
JWT_SECRET="your-jwt-secret-here" \
SENDGRID_API_KEY="SG.xxxx"
# List secret names (values are never shown)
fly secrets list
# Remove a secret
fly secrets unset SENDGRID_API_KEYIn your .NET code, secrets arrive as standard environment variables:
// appsettings.json has the key; the value is overridden by the env var at runtime
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(
// Environment variable DATABASE_URL set via fly secrets
builder.Configuration.GetConnectionString("Default")
?? Environment.GetEnvironmentVariable("DATABASE_URL")
?? throw new InvalidOperationException("DATABASE_URL is not set")
)
);Adding Postgres
Fly.io offers managed Postgres clusters that run as separate Fly apps on your account. They are not serverless — they are persistent VMs with attached volumes.
Create the Postgres Cluster
# Creates a 2-node HA Postgres cluster named "myapi-db" in the iad region
fly postgres create \
--name myapi-db \
--region iad \
--vm-size shared-cpu-1x \
--volume-size 10
# Output includes the connection string — save it, it won't be shown again
# postgres://myapi_db:PASSWORD@myapi-db.flycast:5432/myapi_dbAttach to Your App
# Attaches the Postgres cluster to your app and sets DATABASE_URL automatically
fly postgres attach --app myapi-prod myapi-dbattach creates a database user scoped to your app, sets the DATABASE_URL secret, and configures private networking so your app reaches Postgres over the WireGuard mesh (never over the public internet).
Verify the secret was set:
fly secrets list
# NAME DIGEST CREATED AT
# DATABASE_URL abc123 2025-02-21T10:00:00ZEF Core Connection String
The DATABASE_URL format from Fly is a libpq URI. Npgsql accepts it directly:
// Parse DATABASE_URL from Fly.io format: postgres://user:pass@host:port/db
var rawUrl = builder.Configuration["DATABASE_URL"]
?? Environment.GetEnvironmentVariable("DATABASE_URL");
if (!string.IsNullOrEmpty(rawUrl))
{
var uri = new Uri(rawUrl);
var userInfo = uri.UserInfo.Split(':');
var connectionString = new NpgsqlConnectionStringBuilder
{
Host = uri.Host,
Port = uri.Port == -1 ? 5432 : uri.Port,
Database = uri.AbsolutePath.TrimStart('/'),
Username = userInfo[0],
Password = userInfo.Length > 1 ? userInfo[1] : null,
SslMode = SslMode.Prefer, // Fly internal network is already encrypted via WireGuard
}.ToString();
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(connectionString));
}Running EF Core Migrations on Deploy
The cleanest approach is a release_command in fly.toml — Fly runs it before routing traffic to new machines:
[deploy]
release_command = "dotnet MyApi.dll migrate"Add a custom command handler in Program.cs:
// Check for CLI args before building the full app
if (args.Contains("migrate"))
{
// Build a minimal host just for migration
var host = Host.CreateDefaultBuilder(args)
.ConfigureServices((ctx, services) =>
{
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(ctx.Configuration["DATABASE_URL"]));
})
.Build();
using var scope = host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
Console.WriteLine("Migrations applied successfully.");
return;
}
// Normal app startup continues below...Horizontal Scaling and Multi-Region
Scale Within One Region
# Run 3 machines in the primary region (iad)
fly scale count 3
# Verify
fly statusFly.io's load balancer distributes requests across all healthy machines using least-connections.
Multi-Region Deployment
# Add machines in Frankfurt and Tokyo
fly scale count 1 --region fra
fly scale count 1 --region nrt
# Check placement
fly statusYour fly.toml can pin certain regions:
# Keep at least one machine in each region at all times
[[regions]]
code = "iad"
count = 2
[[regions]]
code = "lhr"
count = 1For .NET APIs with EF Core and a single Postgres cluster, be careful with multi-region writes. Fly's managed Postgres does not automatically replicate writes to replica regions — all writes go to the primary. Use fly-replay headers or route write-heavy endpoints to the primary region.
Custom Domains and TLS
Fly.io provisions TLS certificates automatically via Let's Encrypt.
# Add a custom domain (you must own it)
fly certs add api.yourdomain.com
# Check certificate status
fly certs show api.yourdomain.comThe output includes two DNS records to add at your registrar:
Type Host Value
A api.yourdomain.com 66.241.124.x
AAAA api.yourdomain.com 2a09:8280:1::...Or use a CNAME pointing to myapi-prod.fly.dev. Certificates are issued within minutes of DNS propagation.
Cold Start Behavior
With auto_stop_machines = true and min_machines_running = 0, Fly.io stops your machine after ~5 minutes of no traffic. The next request triggers a cold start.
Cold start timeline for a .NET API:
| Phase | Typical Duration |
|---|---|
| Fly.io VM boot (Firecracker) | ~300 ms |
| Docker image pull (first deploy) | ~0 ms (image is local to the host) |
| .NET runtime init | ~200–600 ms |
| ASP.NET middleware pipeline | ~50–100 ms |
| Total | ~550–1000 ms |
This is fast relative to AWS Lambda's .NET cold starts, but still noticeable for interactive APIs.
How to Avoid Cold Starts
Option 1: Set min_machines_running = 1
[http_service]
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 1 # Always keep one warmThis costs ~$1.94/month for a shared-cpu-1x machine — essentially free for production.
Option 2: Faster .NET startup with Native AOT
<!-- MyApi.csproj — enables Native AOT compilation -->
<PropertyGroup>
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>AOT-compiled .NET apps start in ~50–100 ms total. The tradeoff: longer build times, no runtime reflection, and some NuGet packages are not AOT-compatible.
Option 3: Warm-up request via health check
Configure an external uptime monitor (e.g., BetterUptime, UptimeRobot free tier) to ping /health every 4 minutes. This keeps the machine active without paying for min_machines_running.
Pricing
Fly.io pricing (as of 2026) is usage-based: you pay per second of machine runtime, not provisioned capacity. Stopped machines do not accrue compute charges (you still pay for volumes and reserved IPv4 addresses).
Compute (Shared CPU)
| Machine Size | vCPU | RAM | Price/Month (full) |
|---|---|---|---|
| shared-cpu-1x | 1 shared | 256 MB | ~$1.94 |
| shared-cpu-1x | 1 shared | 512 MB | ~$3.13 |
| shared-cpu-2x | 2 shared | 512 MB | ~$5.70 |
| shared-cpu-4x | 4 shared | 1 GB | ~$10.70 |
Compute (Dedicated CPU)
| Machine Size | vCPU | RAM | Price/Month |
|---|---|---|---|
| performance-1x | 1 dedicated | 2 GB | ~$7.69 |
| performance-2x | 2 dedicated | 4 GB | ~$15.38 |
| performance-4x | 4 dedicated | 8 GB | ~$30.77 |
Postgres
| Plan | RAM | Storage | Price/Month |
|---|---|---|---|
| shared-cpu-1x | 256 MB | 1 GB | ~$1.94 |
| shared-cpu-1x | 256 MB | 10 GB | ~$3.44 |
| performance-1x | 2 GB | 50 GB | ~$26.69 |
Free Trial, Not a Free Tier
Fly.io no longer offers a permanent free allowance. New organizations get a one-time trial credit, and signup requires a credit card — if you can't or won't add one, Fly.io is off the table, and Render's free tier is the closest alternative. Accounts created before the pricing change may still have grandfathered allowances.
That said, scale-to-zero keeps real costs tiny: a shared-cpu-1x machine with auto_stop_machines = true that only runs when traffic arrives bills cents per month for a low-traffic API.
Check your current month's accrual in the Fly.io dashboard under Billing, and set a spending alert to avoid surprises.
Complete Deployment Workflow
Here is the end-to-end sequence from zero to production:
# 1. Install flyctl and authenticate
curl -L https://fly.io/install.sh | sh
fly auth login
# 2. Create the app (generates fly.toml)
fly launch --name myapi-prod --region iad --no-deploy
# 3. Set application secrets
fly secrets set \
JWT_SECRET="$(openssl rand -base64 32)" \
ENVIRONMENT="Production"
# 4. Create and attach Postgres
fly postgres create --name myapi-db --region iad --vm-size shared-cpu-1x
fly postgres attach --app myapi-prod myapi-db
# 5. Deploy
fly deploy
# 6. Verify
fly status
fly logs
# 7. Open in browser
fly openPros and Cons for .NET APIs
Pros
| Aspect | Detail |
|---|---|
| Global Anycast | Route users to nearest region with one IP — no CDN config needed |
| Firecracker VMs | Better isolation than shared containers; predictable latency |
| Private networking | App-to-Postgres traffic stays on WireGuard mesh, never public |
| .NET Docker support | Microsoft's official images work perfectly; no buildpack friction |
| Rolling deploys | Zero-downtime out of the box, no Kubernetes YAML |
| SSH access | fly ssh console gives a real shell for debugging |
| Price | Shared-cpu-1x at $1.94/mo is hard to beat for small APIs |
| Scale-to-zero | Stopped machines bill nothing — idle apps cost cents per month |
Cons
| Aspect | Detail |
|---|---|
| Cold starts | Scale-to-zero means ~1s cold starts; not ideal for SLA-sensitive APIs |
| No managed SQL Server | Fly offers Postgres and Redis; if you need SQL Server, run it yourself |
| Fly Postgres is DIY-ish | It is Postgres on a VM, not a fully managed service like RDS — you manage extensions and backups manually |
| Volume management | Persistent volumes are region-locked; multi-region stateful apps are complex |
| No built-in APM | No Application Insights equivalent; integrate OpenTelemetry + a third-party provider |
| Smaller ecosystem | Fewer tutorials, less StackOverflow coverage than AWS/Azure |
| WireGuard setup | Connecting from your local machine to internal Fly services requires fly proxy or a WireGuard client |
Summary
| Scenario | Recommendation |
|---|---|
| Personal project / side hustle | Scale-to-zero keeps cost near zero — but signup requires a credit card |
| Small production API (<100 req/s) | shared-cpu-1x, min_machines_running = 1, Fly Postgres |
| Global API (low-latency worldwide) | Multi-region deployment with 3–5 machines |
| High-traffic API (>500 req/s) | performance-1x machines + HA Postgres cluster |
| Enterprise / compliance requirements | AWS/Azure (more audit tools, SLAs, SQL Server native) |
| Team already on Kubernetes | Consider Fly.io for smaller services; bigger apps may outgrow it |
Fly.io hits a sweet spot for .NET developers who want Docker-native deployments, global presence, and transparent pricing without the complexity of AWS ECS/EKS or the Azure App Service configuration overhead. The flyctl CLI is genuinely pleasant to use, the WireGuard-based private networking is a security win over traditional VPC peering, and the Firecracker VM isolation means your container behaves like a real machine. The main gotcha for .NET is the Postgres-only managed database offering — if your app requires SQL Server, you are running it on a plain VM.