//JorgenHoc
← All articles
.NET HostingBy Jorge CalderónUpdated 16 min read

Hosting a .NET API on Fly.io — Step by Step

Deploy your .NET API to Fly.io: Dockerfile, fly.toml config, flyctl commands, secrets management, Postgres addon, custom domains, pricing, and honest pros and cons.

#dotnet#cloud#devops#docker

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 .internal DNS 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 CodeLocation
iadAshburn, VA (US)
ordChicago, IL (US)
laxLos Angeles, CA (US)
lhrLondon, UK
fraFrankfurt, Germany
nrtTokyo, Japan
sydSydney, Australia
gruSã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 MyApi

The 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:local

Installing 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 flyctl

Authenticate:

fly auth login

This 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 launch

You will be prompted for:

  1. App name — must be globally unique across all Fly.io customers (e.g., myapi-prod).
  2. Primary region — pick the region closest to your users.
  3. Postgres database — decline here; we set it up separately below.
  4. 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 = 256
⚠️

auto_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

CommandWhat It Does
fly launchInteractive first-deploy wizard
fly deployBuild image and deploy; uses local Docker daemon by default
fly deploy --remote-onlyBuild on Fly's remote builder (no local Docker needed)
fly statusShow running machines and their health
fly logsTail live logs from all machines
fly logs -i <machine-id>Logs from a specific machine
fly ssh consoleOpen a shell inside a running machine
fly scale count 3Scale to 3 machines in the primary region
fly scale count 1 --region lhrEnsure 1 machine in London
fly releasesList all deployments with image tags
fly rollbackRoll back to the previous release
fly apps destroy myapi-prodPermanently 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 logs

Fly.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_KEY

In 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_db

Attach to Your App

# Attaches the Postgres cluster to your app and sets DATABASE_URL automatically
fly postgres attach --app myapi-prod myapi-db

attach 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:00Z

EF 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 status

Fly.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 status

Your 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 = 1
💡

For .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.com

The 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:

PhaseTypical 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 warm

This 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 SizevCPURAMPrice/Month (full)
shared-cpu-1x1 shared256 MB~$1.94
shared-cpu-1x1 shared512 MB~$3.13
shared-cpu-2x2 shared512 MB~$5.70
shared-cpu-4x4 shared1 GB~$10.70

Compute (Dedicated CPU)

Machine SizevCPURAMPrice/Month
performance-1x1 dedicated2 GB~$7.69
performance-2x2 dedicated4 GB~$15.38
performance-4x4 dedicated8 GB~$30.77

Postgres

PlanRAMStoragePrice/Month
shared-cpu-1x256 MB1 GB~$1.94
shared-cpu-1x256 MB10 GB~$3.44
performance-1x2 GB50 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 open

Pros and Cons for .NET APIs

Pros

AspectDetail
Global AnycastRoute users to nearest region with one IP — no CDN config needed
Firecracker VMsBetter isolation than shared containers; predictable latency
Private networkingApp-to-Postgres traffic stays on WireGuard mesh, never public
.NET Docker supportMicrosoft's official images work perfectly; no buildpack friction
Rolling deploysZero-downtime out of the box, no Kubernetes YAML
SSH accessfly ssh console gives a real shell for debugging
PriceShared-cpu-1x at $1.94/mo is hard to beat for small APIs
Scale-to-zeroStopped machines bill nothing — idle apps cost cents per month

Cons

AspectDetail
Cold startsScale-to-zero means ~1s cold starts; not ideal for SLA-sensitive APIs
No managed SQL ServerFly offers Postgres and Redis; if you need SQL Server, run it yourself
Fly Postgres is DIY-ishIt is Postgres on a VM, not a fully managed service like RDS — you manage extensions and backups manually
Volume managementPersistent volumes are region-locked; multi-region stateful apps are complex
No built-in APMNo Application Insights equivalent; integrate OpenTelemetry + a third-party provider
Smaller ecosystemFewer tutorials, less StackOverflow coverage than AWS/Azure
WireGuard setupConnecting from your local machine to internal Fly services requires fly proxy or a WireGuard client

Summary

ScenarioRecommendation
Personal project / side hustleScale-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 requirementsAWS/Azure (more audit tools, SLAs, SQL Server native)
Team already on KubernetesConsider 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.

Further reading

About the author

Jorge Calderón

Software engineer with over a decade building and operating .NET applications in production — EF Core data layers, async-heavy services, and Azure and container deployments. Every benchmark and sample project in these guides is published in a public GitHub repository so you can rerun it yourself.

GitHub profileLinkedIn ↗Benchmarks & sample code

Related articles