---
title: "Next.js hosting on Fly.io: a full-stack React framework on global infrastructure"
description: "Next.js hosting on Fly.io. Deploy a full-stack Next.js app in minutes with flyctl and run it on global infrastructure, close to your users."
---

# Next.js hosting on Fly.io: a full-stack React framework on global infrastructure

Fly.io is a developer-centric, global public cloud with everything you need to deploy your Next.js app at scale.

[Launch Now](https://fly.io/speedrun)

Ready, Set, Go!

## Speedrun Your Next.js App Onto Fly.io

Deploy a Next.js app to Fly.io in a few minutes with Fly.io's command line tool, `flyctl`.

[Get Started](https://docs.fly.io/js/frameworks/nextjs)

```
> Install flyctl on GNU/Linux
$ curl -L https://fly.io/install.sh | sh

> Run from Next.js project root
$ fly launch

> Scale CPU, memory, instances & regions
$ fly scale
```

Not on GNU/Linux? [Install flyctl for your platform.](https://docs.fly.io/flyctl/install)

## Everything on the Edge – Including Data

Launch your Next.js app – any and everything it depends on – in the regions closest to your users. Fly.io partners offering globally-distributed databases, message queues, and S3-compatible object storage. Distance is no issue when your whole app can be everywhere.

## Private Networks, WireGuard

Fly Apps get their own private network by default, making it easier than ever to connect your services on other platforms securely. Additionally, using WireGuard you can easily connect existing services on other networks to your apps on Fly.io.

NEW!

## Managed Postgres for Next.js Projects

Focus on features and leave database management to Fly.io. Our Managed Postgres service, gives you:

- Automatic backups and recovery
- High availability with automatic failover
- Performance monitoring and metrics
- Resource scaling (CPU, RAM, storage)
- 24/7 support and incident response
- Automatic encryption of data at rest and in transit

[Learn More](https://fly.io/mpg/)

## Only Pay for What You Use

Get the benefits of a full server without the overhead. With fast-booting VMs that automatically start and stop when they need to, you don't have to worry about paying for resources not in use.

## Trusted by teams at

- **Bare Metal – Not a Big Cloud Abstraction**
  No longer do you have to choose between great developer experience and reasonable pricing. Fly.io is its own cloud with physical servers in data centers; scale your apps without losing sleep over accidentally going viral.
- **Generated Dockerfiles for SSG & SSR**
  Whether you're serving static pages or rendering content server-side, we make it easy to generate Dockerfiles for optimized images so you can focus on building amazing apps.
- **Globally Distributed Object Storage, No CDN Required**
  Tigris Data is an S3-compatible object storage solution that stores assets globally by default. Setup a bucket in seconds with a single command. Great developer experience. Zero egress costs.
- **Managed Data Services for Every Occasion**
  Keep your data closest to your users. Supabase for Postgres. LiteFS for SQLite. Upstash for Redis®. We partner with managed data services on top of our infrastructure for extremely low-latency.

## Ready to Take Flight?

Launch your Next.js app on Fly.io in just minutes. We have everything you need to build scalable applications on our worldwide network of bare metal machines.

[Get Started](https://fly.io/app/sign-up)

## Deploy a Next.js app to Fly.io: the actual flow

The flow is three commands: install `flyctl`, run `fly launch`, then `fly deploy` for every change after that.

Install the CLI:

```
curl -L https://fly.io/install.sh | sh
```

`flyctl` is the only tool you need locally. Authenticate with `fly auth login` once.

Kick off your first deploy from your Next.js project root:

```
cd path/to/your/nextjs/app
fly launch
```

`fly launch` detects Next.js by reading `package.json`, picks a region close to you, generates a `Dockerfile` and a `fly.toml` in your repo, and offers to provision a Postgres database and Redis. If you've set `output: 'standalone'` in `next.config.js` (which we recommend; see the next section), the generated `Dockerfile` uses that layout for a smaller image.

For every change after the first deploy, run `fly deploy`. That rebuilds the image, pushes it to our registry, and rolls a new version onto your Machines with no downtime.

The generated `fly.toml` has an `http_service` block that maps your container's `PORT` to Fly.io's edge proxy. The generated `Dockerfile` installs your `node_modules`, runs `npm run build` (or yarn / pnpm equivalent), and starts the Next.js server on that `PORT`.

## Use standalone output for smaller images

Standalone output is a Next.js build mode that produces a self-contained `.next/standalone` directory with only the runtime files your app needs. The result is a smaller Docker image, faster cold starts, and no devDependencies in the deployed Machine.

Enable it in `next.config.js`:

```
module.exports = {
  output: 'standalone',
}
```

If you set this before running `fly launch`, the generated `Dockerfile` uses the standalone layout (copies `.next/standalone` into the runtime image, sets the entrypoint to `node server.js`). If you add it after the fact, regenerate the Dockerfile or hand-edit it to match.

The running app behaves identically to a default build. The one thing to watch: files you reference at runtime from outside `.next` (custom static files, locale data for next-intl, font files) need to be copied into the standalone directory at build time. Next.js auto-traces most of this, but custom paths can slip through. If you see `ENOENT: no such file or directory` at runtime, add the missing path to `outputFileTracingIncludes` in `next.config.js`.

## Env vars: build-time vs runtime

Next.js has two flavors of environment variable, and Fly.io handles each differently.

`NEXT_PUBLIC_*` values are inlined into the JavaScript bundle during `next build`, so they have to be available at `fly deploy` time. Declare them under `[build.args]` in `fly.toml` and pass concrete values on each deploy:

```
[build]
[build.args]
  NEXT_PUBLIC_API_URL = ""
  NEXT_PUBLIC_SUPABASE_URL = ""
```

```
fly deploy \
  --build-arg NEXT_PUBLIC_API_URL=https://api.example.com \
  --build-arg NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
```

Don't put the actual values in `fly.toml`. The file is committed to source control; the values get baked into the public client bundle on every build anyway, but committing them couples your environments to your repo.

Server-only secrets (database URLs, OAuth client secrets, Stripe keys, JWT signing keys) belong in `fly secrets set`. They're injected as environment variables when each Machine boots and never make it into the JavaScript bundle:

```
fly secrets set DATABASE_URL=postgres://... STRIPE_SECRET_KEY=sk_live_...
```

Setting one or more secrets triggers a rolling restart. Read them server-side in route handlers, server components, or server actions via `process.env.NAME`.

The reason for two mechanisms: Next.js runs both at build time (collecting static prerendered pages, inlining client values) and at runtime (serving dynamic requests). Anything the browser needs has to be there at build time; anything only the server uses can wait until boot.

## Make server actions work across multiple Machines

This is a Next.js-specific failure mode that bites every multi-Machine Fly.io deploy of an App Router app: server actions stop working after a deploy with errors like `Failed to find Server Action`.

Next.js encrypts server action closures with a key that gets generated per build and embedded into the bundle. Each Machine has its own build, so each Machine has its own key. When a user's browser invokes a server action from Machine A's bundle but the request lands on Machine B, Machine B can't decrypt the payload and rejects it.

The fix is to pin the key explicitly so every build uses the same value:

```
openssl rand -base64 32
```

The output of that command is your key. The catch: `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` has to be set at **build** time, not at runtime. It gets embedded into the build output and used automatically when the server starts. `fly secrets set` would put the value in the runtime environment but not in the build, so it wouldn't fix the problem. Use `fly deploy --build-arg` or declare it in `fly.toml [build.args]` instead:

```
[build]
[build.args]
  NEXT_SERVER_ACTIONS_ENCRYPTION_KEY = ""
```

```
fly deploy --build-arg NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=your-generated-key
```

Generate the value once, store it wherever you keep other build-time secrets (CI variable, password manager, a `.env` file that's not committed), and reuse it across every deploy. If you rotate the key, all previously-deployed bundles immediately stop accepting server actions, so coordinate the rotation with a full deploy.

## Attach Managed Postgres without breaking the build

The recommended path is Fly.io Managed Postgres. MPG handles automatic backups and recovery, high availability with automatic failover, performance monitoring, resource scaling, and encryption of data at rest and in transit. We no longer support the older unmanaged `fly postgres` path; MPG is the supported way to run Postgres on Fly.io.

Provision a cluster and attach it:

```
fly mpg create
fly mpg list
fly mpg attach your-cluster-id -a your-app-name
```

`fly mpg create` walks through plan, region, and volume size. `fly mpg list` shows the cluster ID once it's ready. `fly mpg attach` writes a `DATABASE_URL` secret onto your app, reboots it, and uses a PGBouncer-pooled URL.

The Next.js-specific pitfall: the Fly.io remote builder doesn't have your app's secrets at build time, so any route that reads `process.env.DATABASE_URL` during static prerendering fails. Two ways to handle it.

Mark the route as dynamic so Next.js skips it at build time:

```
export const dynamic = 'force-dynamic'
```

Or use the `connection()` API to defer rendering to request time:

```
import { connection } from 'next/server'

export default async function Page() {
  await connection()
  const users = await db.query.users.findMany()
  return <UserList users={users} />
}
```

`connection()` tells Next.js the component depends on the request, so the renderer skips it at build time and runs it on each request. This is the cleaner pattern for App Router apps; `force-dynamic` is the heavier hammer.

Database access at runtime works fine; it's only the build phase that's sandboxed.

## What you didn't have to set up

Look back at what actually happened. You ran `fly launch`, then `fly mpg create` and `fly mpg attach`. A standard Next.js app with a Postgres database in one region is the ordinary case Fly.io is built for, not a stretch. Here is the work that never appeared:

**A hosting service for your code.** `fly launch` and `fly deploy` build from the directory you are standing in. A Next.js project with no git remote at all deploys fine.

**A Dockerfile.** `fly launch` detects Next.js and writes one, along with the `fly.toml`. Both land in your repository where you can read them, edit them, and see them in a diff.

**A connection string.** `fly mpg attach` writes `DATABASE_URL` onto the app as a secret. It is there for your server components and route handlers at runtime, and the password never lands in your clipboard or your shell history.

**A migration step in a deploy pipeline.** `fly launch` writes `release_command` into `fly.toml`, so your migration command runs on a throwaway Machine before any Machine takes traffic, whether that is `prisma migrate deploy`, `drizzle-kit migrate`, or something else. A migration that fails fails the deploy, instead of half migrating production.

**TLS and a hostname.** The app answers on `<app>.fly.dev` with HTTPS from the first request, and the certificate is issued and renewed for you.

**A load balancer or a VPC.** Fly Proxy is already in front of your Machines, and your app reaches Postgres over your organization's private network as soon as the cluster is attached.

None of that work is missing. It is done, and the record of it is a `fly.toml` you can read in one screen, diff in a pull request, and revert with git.

## Run Next.js across regions

Next.js apps deploy in one region by default. To run in more, use `fly scale count`:

```
fly scale count 3 --region ord,fra,nrt
```

That places Machines in Chicago, Frankfurt, and Tokyo. Fly.io's proxy routes each incoming request to the nearest healthy Machine, so users in Europe hit Frankfurt, users in Asia hit Tokyo, and so on. Server components, server actions, and API routes all see shorter round-trip times, especially for chatty endpoints that the client calls multiple times per page load.

Two things to coordinate for multi-region.

First, `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` has to be the same across every Machine's build. The previous section covers how to pin it.

Second, a Managed Postgres cluster lives in one region with a primary and a replica for in-region high availability. There are no cross-region read replicas; reads from Frankfurt back to a Chicago primary add latency you can't hide at the app layer.

The pragmatic move is to keep MPG in the region where most writes originate, run Next.js Machines globally in front of it, and cache hot read paths (Upstash Redis for sessions and rate limiting, Next.js fetch cache for upstream data, ISR for content) to absorb cross-region latency.

## next/image and Tigris for global delivery

The `next/image` optimizer runs in the Node process by default, so it works on every Fly.io Machine with zero configuration. Each image hit goes through the running Next.js server, gets resized and re-encoded on the fly, and the optimized result is cached on the Machine's local disk.

For most apps this is fine. The optimizer is fast, the cached results are reusable, and a Machine that's serving images is also serving routes, so there's no extra infrastructure to manage.

For very high traffic or globally distributed media (user uploads, large image catalogs), point `next/image` at Tigris instead. Tigris is an S3-compatible object store with edge replication, available on Fly.io. Configure a custom loader in `next.config.js` to route image URLs through Tigris:

```
module.exports = {
  images: {
    loader: 'custom',
    loaderFile: './lib/tigris-loader.js',
  },
}
```

Your loader returns a Tigris URL with the requested width and quality. The optimization happens at the edge, your Machines stop spending CPU on image work, and image bytes get served from the closest Tigris point of presence.
