Deploy your Strapi v5 headless CMS to Fly.io's global edge infrastructure. This guide covers Docker configuration, PostgreSQL setup, persistent storage for media uploads, and production best practices for full-stack developers.

Fly.io is a platform-as-a-service that runs Docker containers as Firecracker micro-VMs distributed globally across edge locations. According to Fly.io's official architecture documentation, the platform converts Docker containers into Firecracker micro-VMs—lightweight virtualized containers that provide full hardware virtualization rather than just container isolation.
This approach delivers strong VM-level security isolation with millisecond startup times enabling rapid scaling, along with efficient resource usage where many micro-VMs run on single physical hosts with minimal overhead.
Building with Strapi's headless CMS gives you API-first content management. Deploying to Fly.io solves the infrastructure complexity that typically comes with self-hosting. Here's why this combination works well for full-stack developers:
flyctl CLI enables Docker-based deployment workflows that integrate directly with Git repositories. Developers maintain deployment configurations through fly.toml files stored in version control alongside application code.Follow these steps to deploy your Strapi v5 application to Fly.io with PostgreSQL, persistent storage, and production-ready configuration.
Before starting, ensure you have:
The flyctl command-line tool manages your entire deployment workflow. Install it using the appropriate command for your operating system:
# macOS and Linux
curl -L https://fly.io/install.sh | sh
# Windows (PowerShell)
iwr https://fly.io/install.ps1 -useb | iexAfter installation, authenticate with your Fly.io account:
flyctl auth loginThis opens a browser window for authentication. Once you complete the authentication process, your terminal session has access to deploy and manage applications.
Strapi v5 deployments require a multi-stage Docker build that separates the admin panel compilation from the production runtime. Create a Dockerfile in your project root:
# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm install
# Copy application files and build admin panel
COPY . .
RUN npm run build
# Production stage
FROM node:18-alpine AS production
WORKDIR /app
# Copy built application from builder
COPY --from=builder /app .
# Remove dev dependencies for smaller image
RUN npm prune --production
# Create uploads directory with correct permissions
RUN mkdir -p /app/public/uploads && chown -R node:node /app/public/uploads
# Expose Strapi port
EXPOSE 1337
# Start Strapi in production mode
CMD ["npm", "run", "start"]The multi-stage approach keeps your production image lean by excluding build-time dependencies. In the production stage, the npm prune --production command removes development packages that aren't needed at runtime, reducing the final Docker image size and attack surface.
fly.tomlThe fly.toml file defines your application's deployment configuration. Create this file in your project root:
app = "your-strapi-app-name"
primary_region = "iad"
[build]
dockerfile = "Dockerfile"
[mounts]
source = "strapi_uploads"
destination = "/app/public/uploads"
[env]
NODE_ENV = "production"
HOST = "0.0.0.0"
PORT = "1337"
[[services]]
internal_port = 1337
protocol = "tcp"
auto_stop_machines = true
auto_start_machines = true
min_machines_running = 1
[[services.ports]]
handlers = ["http"]
port = 80
force_https = true
[[services.ports]]
handlers = ["tls"]
port = 443
[[services.http_checks]]
interval = 10000
timeout = 5000
grace_period = "30s"
method = "GET"
path = "/_health"
[[vm]]
cpu_kind = "shared"
cpus = 1
memory_mb = 2048A few configuration points that matter:
HOST = "0.0.0.0" is required—Strapi must bind to all interfaces for container networking to work correctly.grace_period gives Strapi time to initialize database connections and compile content-type schemas before health checks begin.memory_mb = 2048 is the minimum recommended for production Strapi instances; insufficient memory causes most deployment failures.With your configuration files ready, initialize the application without deploying immediately:
flyctl launch --no-deployThe --no-deploy flag prevents immediate deployment, allowing configuration of database and secrets before the first deployment.
Fly.io provides managed PostgreSQL clusters. Create one for your Strapi application:
flyctl postgres create --name your-strapi-dbThe CLI prompts you for configuration options. For development, a single-node cluster works fine. For production, select the high-availability option with a leader-replica configuration, as documented in Fly.io's Postgres cluster creation process.
Save the credentials immediately—Fly.io displays the PostgreSQL password once during cluster creation and cannot retrieve it later.
Attach the database to your application:
flyctl postgres attach your-strapi-db --app your-strapi-app-nameAttaching the database automatically creates a DATABASE_URL secret in your application's environment.
Without persistent storage, uploaded media files disappear when your application redeploys. Create a volume in the same region as your application:
flyctl volumes create strapi_uploads --region iad --size 10The volume name must match the source field in your fly.toml mounts configuration exactly. Start with 10GB—you can extend volumes later but cannot shrink them.
Strapi's security configuration requires several cryptographic secrets for production deployments. According to the official documentation, these mandatory environment variables include APP_KEYS (comma-separated list of signing keys), API_TOKEN_SALT (for API token generation), ADMIN_JWT_SECRET (for admin panel authentication), JWT_SECRET (for general JWT operations), and TRANSFER_TOKEN_SALT (for content transfer tokens).
Generate secure random values using a cryptographically secure method (such as openssl rand -base64 32) and set them using fly secrets set to ensure they are injected as environment variables without being stored in version control.
# Generate secrets (run each separately)
openssl rand -base64 32
# Set all required secrets
flyctl secrets set \
APP_KEYS="base64-key-1,base64-key-2,base64-key-3,base64-key-4" \
API_TOKEN_SALT="your-random-salt" \
ADMIN_JWT_SECRET="your-admin-jwt-secret" \
JWT_SECRET="your-jwt-secret" \
TRANSFER_TOKEN_SALT="your-transfer-salt"Each secret serves a specific purpose in Strapi's security model:
APP_KEYS: Session encryption (comma-separated list of keys).API_TOKEN_SALT: Generates API tokens for programmatic access.ADMIN_JWT_SECRET: Authenticates Admin Panel sessions.JWT_SECRET: Signs tokens for user authentication.TRANSFER_TOKEN_SALT: Required for Strapi v5's content transfer features.Modify your config/database.js to work with Fly.io's PostgreSQL:
module.exports = ({ env }) => ({
connection: {
client: 'postgres',
connection: {
connectionString: env('DATABASE_URL'),
ssl: env.bool('DATABASE_SSL', false) && {
rejectUnauthorized: env.bool('DATABASE_SSL_REJECT_UNAUTHORIZED', true),
},
},
pool: {
min: 0,
max: 10,
acquireTimeoutMillis: 30000,
createTimeoutMillis: 30000,
destroyTimeoutMillis: 5000,
idleTimeoutMillis: 30000,
reapIntervalMillis: 1000,
createRetryIntervalMillis: 100
},
debug: false,
},
});The pool.min: 0 setting is critical for Strapi on Fly.io. Fly.io uses PgBouncer for connection pooling, and according to the official Strapi database configuration documentation, setting the minimum pool size to 0 prevents issues with idle connections being killed in containerized environments. Setting a minimum above zero can cause connection exhaustion in these environments, as documented in Strapi deployment best practices.
Install the PostgreSQL client if you haven't already:
npm install pg --saveWith everything configured, deploy your Strapi application:
flyctl deployThe deployment process builds your Docker image, pushes it to Fly.io's registry, creates VM instances in your specified region, mounts the persistent volume, injects secrets as environment variables, executes health checks, and routes traffic only to healthy instances.
Monitor the deployment:
flyctl logsOnce health checks pass, open your application:
flyctl openNavigate to https://your-app-name.fly.dev/admin to create your first administrator account and start building your content types.
Let's walk through a practical implementation: a travel blog where Strapi manages content and a Next.js frontend consumes the API. This pattern is common for headless CMS architectures where content teams and developers work independently.
In your Strapi Admin Panel, create three content types:
Article Collection Type:
title (Text, required) - The article headline displayed in content lists and detail pages.slug (UID, based on title) - URL-friendly identifier generated from the title for SEO-optimized URLs.content (Rich Text with Blocks editor) - Main article body supporting formatted text, images, and dynamic content blocks.excerpt (Text, max 200 characters) - Summary text for preview cards and metadata.coverImage (Media, single image) - Featured image displayed in article listings and detail pages.publishedAt (DateTime) - Publication timestamp controlling content visibility and sorting.author (Relation to Author) - Link to the author collection establishing content ownership.destinations (Relation to Destination, many) - Multi-reference field linking articles to related travel destinations.Author Collection Type:
name (Text, required).bio (Text).avatar (Media, single image).articles (Relation to Article, one-to-many).Destination Collection Type:
name (Text, required).slug (UID).country (Text).description (Rich Text).featuredImage (Media).articles (Relation to Article, many-to-many).In Settings > Users & Permissions > Roles, configure the Public role to allow find and findOne operations on your content types. This enables unauthenticated API access for your frontend.
Create a utility function to fetch content from your deployed Strapi instance:
// lib/strapi.ts
const STRAPI_URL = process.env.NEXT_PUBLIC_STRAPI_URL || 'https://your-strapi-app.fly.dev';
interface StrapiResponse<T> {
data: T;
meta: {
pagination?: {
page: number;
pageSize: number;
pageCount: number;
total: number;
};
};
}
export async function fetchAPI<T>(
path: string,
options: RequestInit = {}
): Promise<StrapiResponse<T>> {
const response = await fetch(`${STRAPI_URL}/api${path}`, {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
});
if (!response.ok) {
throw new Error(`Strapi API error: ${response.status}`);
}
return response.json();
}
export async function getArticles() {
return fetchAPI<Article[]>('/articles?populate=coverImage,author');
}
export async function getArticleBySlug(slug: string) {
return fetchAPI<Article[]>(
`/articles?filters[slug][$eq]=${slug}&populate=coverImage,author,destinations`
);
}
export async function getDestinations() {
return fetchAPI<Destination[]>('/destinations?populate=featuredImage');
}Implement dynamic routing with static generation for individual articles:
// app/articles/[slug]/page.tsx
import { getArticleBySlug, getArticles } from '@/lib/strapi';
import { notFound } from 'next/navigation';
export async function generateStaticParams() {
const { data: articles } = await getArticles();
return articles.map((article) => ({
slug: article.attributes.slug,
}));
}
export default async function ArticlePage({
params,
}: {
params: { slug: string };
}) {
const { data: articles } = await getArticleBySlug(params.slug);
if (!articles || articles.length === 0) {
notFound();
}
const article = articles[0];
return (
<article>
<h1>{article.attributes.title}</h1>
<p>{article.attributes.content}</p>
</article>
);
}This pattern implements Static Site Generation (SSG) where Next.js pre-builds pages at deployment time by querying your Strapi API (deployed on Fly.io) and generating HTML for each article slug. Benefits include:
For production deployments, add these environment variables to your Next.js project:
NEXT_PUBLIC_STRAPI_URL=https://your-strapi-app.fly.dev
STRAPI_API_TOKEN=your-api-token-from-strapi-adminAccording to the Strapi deployment documentation, your API token is generated in the Strapi admin panel at /admin/settings/api-tokens after deploying to Fly.io.
The Strapi integrations page documents additional frontend framework combinations if you prefer React, Vue, or other options.
If you have questions about deploying Strapi v5 to Fly.io or want to discuss your specific architecture, join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord.
For more details, visit the Strapi documentation and Fly.io documentation.
Production Strapi deployments on Fly.io typically cost between $48-68 per month under standard configurations. This breaks down to approximately $10-30 for compute (depending on machine size and CPU/RAM allocation), $38 for the entry-level managed PostgreSQL cluster, and $0.15 per GB per month for persistent volume storage. Additional costs may apply for bandwidth at $0.02 per GB. Fly.io's auto-stop feature can reduce costs during low-traffic periods by stopping machines when not in use, and annual reservations provide up to 40% discount on compute charges for committed workloads.
While technically possible with persistent volumes, SQLite is strongly discouraged for production Strapi deployments. According to the official Strapi v5 deployment documentation, SQLite is recommended mainly for development or prototyping due to its concurrency limitations, lack of horizontal scaling capabilities, and risks of data corruption. PostgreSQL handles concurrent requests properly and provides the high availability options that production applications require. Fly.io's managed PostgreSQL service starts at $38/month and provides the robust database infrastructure that production Strapi deployments need.
Uploaded images disappear after redeployment because Fly.io uses ephemeral filesystems by default. Without persistent storage configuration, all files stored on the instance's local filesystem are deleted when the application is redeployed or the machine restarts. To persist uploaded media files across redeployments, you must mount a Fly.io volume to your application.
Configure persistent volumes by adding a [mounts] section to your fly.toml pointing to /app/public/uploads, then create the volume with flyctl volumes create. Alternatively, use cloud storage providers like AWS S3 or Cloudinary, which eliminates volume management entirely.
Fly.io integrates with GitHub Actions for automated deployments through the official flyctl GitHub Action. First, generate a deploy token with fly tokens create deploy and store it as a GitHub secret named FLY_API_TOKEN. Then create a workflow file that uses the official action to run flyctl deploy --remote-only on pushes to your main branch. The action handles authentication automatically using your FLY_API_TOKEN secret. Ensure your fly.toml configuration file is committed to the repository, as it's required for the deployment process.
Strapi requires a minimum of 2GB VM memory for stable production operation on Fly.io. Configure this in your fly.toml under [[vm]] with memory_mb = 2048. Additionally, set the Node.js heap size to match by adding NODE_OPTIONS = "--max-old-space-size=2048" to your environment variables. According to the research, insufficient memory is the primary cause of Strapi deployment failures on Fly.io, manifesting as random crashes, failed health checks, or deployments that reset unexpectedly. The research identifies memory as the most critical resource constraint across deployment, scaling, and troubleshooting scenarios.