Deploy your Elixir Phoenix backend to Fly.io. Optimized for Elixir with clustering, hot upgrades, and managed PostgreSQL.

18 min read

Deploying Phoenix to Fly.io

Fly.io is the premier platform for deploying Elixir applications. With first-class support for the BEAM VM, clustering, and hot code reloading, it's the ideal choice for Phoenix deployments.

Why Fly.io for Elixir?

  • Native Elixir support: Optimized for the BEAM VM
  • Clustering: Connect nodes across regions
  • Hot upgrades: Deploy without downtime
  • Managed PostgreSQL: Easy database setup
  • Global deployment: Run close to your users
  • Competitive pricing: Pay for what you use

Prerequisites

  • Fly.io account (signup)
  • Fly CLI installed
  • Phoenix application ready
  • Git repository

Step 1: Install Fly CLI

macOS

brew install flyctl

Linux

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

Windows

# PowerShell
iwr https://fly.io/install.ps1 -useb | iex

Verify Installation

fly version

Step 2: Login to Fly.io

fly auth login

This opens a browser to authenticate.

Step 3: Prepare Your Phoenix App

Release Configuration

Create backend/rel/overlays/bin/migrate:

#!/bin/sh
cd -P -- "$(dirname -- "$0")"
exec ./task_master eval TaskMaster.Release.migrate

Make it executable:

chmod +x backend/rel/overlays/bin/migrate

Create backend/lib/task_master/release.ex:

defmodule TaskMaster.Release do
  @moduledoc """
  Used for executing DB release tasks when run in production without Mix
  """
  @app :task_master

  def migrate do
    load_app()

    for repo <- repos() do
      {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
    end
  end

  def rollback(repo, version) do
    load_app()
    {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
  end

  defp repos do
    Application.fetch_env!(@app, :ecto_repos)
  end

  defp load_app do
    Application.load(@app)
  end
end

Runtime Configuration

Update backend/config/runtime.exs:

import Config

# Get environment variables
secret_key_base =
  System.get_env("SECRET_KEY_BASE") ||
    raise """
    environment variable SECRET_KEY_BASE is missing.
    You can generate one by calling: mix phx.gen.secret
    """

guardian_secret_key =
  System.get_env("GUARDIAN_SECRET_KEY") ||
    raise """
    environment variable GUARDIAN_SECRET_KEY is missing.
    """

app_name =
  System.get_env("FLY_APP_NAME") ||
    raise "FLY_APP_NAME not available"

config :task_master, TaskMasterWeb.Endpoint,
  server: true,
  url: [host: "#{app_name}.fly.dev", port: 443],
  http: [
    ip: {0, 0, 0, 0, 0, 0, 0, 0},
    port: String.to_integer(System.get_env("PORT") || "4000")
  ],
  secret_key_base: secret_key_base

config :task_master, TaskMaster.Guardian,
  issuer: "task_master",
  secret_key: guardian_secret_key

# Database configuration
database_url =
  System.get_env("DATABASE_URL") ||
    raise """
    environment variable DATABASE_URL is missing.
    For example: ecto://USER:PASS@HOST/DATABASE
    """

config :task_master, TaskMaster.Repo,
  url: database_url,
  pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
  socket_options: ipv6_socket_options_if_present

# Configure Swoosh for emails (if using)
config :task_master, TaskMaster.Mailer,
  adapter: Swoosh.Adapters.Resend,
  api_key: System.get_env("RESEND_API_KEY")

# Frontend URL for CORS
config :task_master, :frontend_url, System.get_env("FRONTEND_URL", "http://localhost:5173")

# Helper for IPv6
ipv6_socket_options_if_present = if System.get_env("ECTO_IPV6"), do: [:inet6], else: []

Dockerfile

Create backend/Dockerfile (if not exists):

# Find eligible builder and runner images on Docker Hub
# https://hub.docker.com/r/hexpm/elixir/tags
FROM hexpm/elixir:1.16.0-erlang-26.2.1-debian-bookworm-20231009-slim as builder

# Install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
    && apt-get clean && rm -f /var/lib/apt/lists/*_*

# Prepare build directory
WORKDIR /app

# Install hex + rebar
RUN mix local.hex --force && \
    mix local.rebar --force

# Set build environment
ENV MIX_ENV="prod"

# Install mix dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config

# Copy compile-time config files
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile

COPY priv priv
COPY lib lib
COPY assets assets

# Compile assets
RUN mix assets.deploy

# Compile the release
RUN mix compile

# Changes to config/runtime.exs don't require recompiling
COPY config/runtime.exs config/

COPY rel rel
RUN mix release

# Start a new build stage
FROM debian:bookworm-slim as runner

RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
    && apt-get clean && rm -f /var/lib/apt/lists/*_*

# Set locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen

ENV LANG en_US.UTF-8
ENV LANGUAGE en_US:en
ENV LC_ALL en_US.UTF-8

WORKDIR "/app"
RUN chown nobody /app

# Set runner environment
ENV MIX_ENV="prod"

# Only copy the final release
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/task_master ./

USER nobody

CMD ["/app/bin/server"]

Create backend/.dockerignore:

# This file excludes paths from the Docker build context.
.git
.gitignore

# Build artifacts from the host
/_build/
/assets/node_modules/
/deps/
/doc/
/tmp/

# IDE
.elixir_ls
.vscode
.idea

# OS
.DS_Store

Step 4: Launch on Fly.io

Initialize Fly App

From your backend directory:

cd backend
fly launch

This will:

  1. Detect your Phoenix app
  2. Suggest configuration
  3. Create fly.toml
  4. Set up secrets

When prompted:

  • App name: your-app-name (must be globally unique)
  • Region: Choose closest to you
  • PostgreSQL: Yes (creates managed database)
  • Redis: Optional (for caching/sessions)

Configure fly.toml

Edit the generated backend/fly.toml:

app = 'your-app-name'
primary_region = 'iad'

[build]

[deploy]
  release_command = '/app/bin/migrate'

[env]
  PHX_HOST = 'your-app-name.fly.dev'
  PORT = '8080'
  FRONTEND_URL = 'https://your-frontend.pages.dev'

[http_service]
  internal_port = 8080
  force_https = true
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 0
  processes = ['app']

  [http_service.concurrency]
    type = 'connections'
    hard_limit = 1000
    soft_limit = 1000

[[vm]]
  memory = '1gb'
  cpu_kind = 'shared'
  cpus = 1

Set Secrets

# Generate secrets
SECRET_KEY_BASE=$(mix phx.gen.secret)
GUARDIAN_SECRET_KEY=$(mix phx.gen.secret)

# Set on Fly
fly secrets set SECRET_KEY_BASE="$SECRET_KEY_BASE"
fly secrets set GUARDIAN_SECRET_KEY="$GUARDIAN_SECRET_KEY"
fly secrets set FRONTEND_URL="https://your-frontend.pages.dev"

Step 5: Database Setup

If you created a PostgreSQL cluster during launch:

Attach Database

fly postgres attach --app your-app-name your-db-name

This sets the DATABASE_URL secret automatically.

Manual Migration

If you need to run migrations manually:

fly ssh console -C "/app/bin/migrate"

Database Console

Connect to your database:

fly postgres connect -a your-db-name

Step 6: Deploy

First Deploy

fly deploy

Watch the deployment progress. First deploy takes longer due to building.

Verify Deployment

# Check status
fly status

# View logs
fly logs

# Open app
fly open

Test your API:

curl https://your-app-name.fly.dev/api/health

Step 7: Custom Domain

Add Certificate

fly certs create tcs-svc-9k2m.yourdomain.com

Configure DNS

At your DNS provider:

Type: CNAME
Name: tcs-svc-9k2m
Value: your-app-name.fly.dev

Or for A/AAAA records, get IPs:

fly ips list

Then create:

Type: A
Name: tcs-svc-9k2m
Value: (IPv4 from fly ips list)

Type: AAAA
Name: tcs-svc-9k2m
Value: (IPv6 from fly ips list)

Security Note: Use an obscure subdomain name like tcs-svc-9k2m instead of predictable names like api or backend. Attackers scan for common subdomains automatically. An unguessable name protects your backend from automated reconnaissance and reduces attack volume.

Update fly.toml

[env]
  PHX_HOST = 'tcs-svc-9k2m.yourdomain.com'

Redeploy:

fly deploy

Step 8: Continuous Deployment

GitHub Actions

Create .github/workflows/deploy.yml:

name: Deploy to Fly.io

on:
  push:
    branches: [main]
    paths:
      - 'backend/**'

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy to Fly.io
        uses: superfly/flyctl-actions/setup-flyctl@master
      - run: flyctl deploy --remote-only
        working-directory: backend
        env:
          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

Add FLY_API_TOKEN to GitHub secrets:

fly tokens create deploy -x 999999h
# Copy token to GitHub

Step 9: Monitoring

Fly Dashboard

Monitor at fly.io/dashboard:

  • CPU/Memory usage
  • Request metrics
  • Error rates
  • Logs

Fly Logs

# Real-time logs
fly logs

# Filter logs
fly logs --verbose

Sentry Integration

Add to mix.exs:

defp deps do
  [
    {:sentry, "~> 9.0"}
  ]
end

Configure in config/prod.exs:

config :sentry,
  dsn: System.get_env("SENTRY_DSN"),
  environment_name: :prod,
  enable_source_code_context: true,
  root_source_code_paths: [File.cwd!()]

Step 10: Scaling

Vertical Scaling

Increase VM size:

[[vm]]
  memory = '2gb'
  cpu_kind = 'shared'
  cpus = 2

Then deploy:

fly deploy

Horizontal Scaling

Run multiple instances:

fly scale count 2

Regional Scaling

Deploy to multiple regions:

fly regions add lax
fly regions add lhr
fly deploy

Advanced Configuration

Clustering

Enable node clustering in runtime.exs:

# Clustering configuration
case System.get_env("FLY_APP_NAME") do
  nil ->
    # Local development
    :ok

  app_name ->
    config :libcluster,
      topologies: [
        fly6pn: [
          strategy: Cluster.Strategy.DNSPoll,
          config: [
            polling_interval: 5_000,
            query: "#{app_name}.internal",
            node_basename: app_name
          ]
        ]
      ]
end

Add to supervision tree in application.ex.

Health Checks

Add to fly.toml:

[[services.http_checks]]
  interval = 10000
  grace_period = "5s"
  method = "get"
  path = "/api/health"
  protocol = "http"
  timeout = 2000
  tls_skip_verify = false

Create health endpoint in router.ex:

get "/api/health", HealthController, :index

Hot Upgrades

Enable zero-downtime deploys:

[deploy]
  strategy = "rolling"

Troubleshooting

App Won't Start

Check logs:

fly logs

Common issues:

  • Missing secrets
  • Database connection failed
  • Port misconfiguration

Database Connection Failed

Verify DATABASE_URL:

fly secrets list

Check attachment:

fly postgres list
fly postgres attach --app your-app your-db

Out of Memory

Scale up:

[[vm]]
  memory = '2gb'

Or reduce concurrency:

[http_service.concurrency]
  hard_limit = 500
  soft_limit = 250

Cost Optimization

Auto-Stop Machines

[http_service]
  auto_stop_machines = true
  auto_start_machines = true
  min_machines_running = 0

App stops when idle, starts on request (adds ~500ms cold start).

Volume Mounts

For file uploads, use volumes:

fly volumes create uploads --size 10

Update fly.toml:

[mounts]
  source = "uploads"
  destination = "/app/uploads"

Next Steps