← Back to blog

The Complete Guide to Deploying Elixir/Phoenix Apps

2026-03-05 10 min read

Elixir and Phoenix have earned a well-deserved reputation for building real-time, fault-tolerant web applications. The BEAM virtual machine gives you incredible concurrency, LiveView eliminates the need for JavaScript-heavy frontends, and OTP supervision trees keep your system resilient. But when it comes time to deploy your Phoenix app to production, things can get surprisingly tricky.

Unlike a typical Node.js or Python app, Elixir deployment requires building a compiled release, handling asset pipelines, running Ecto migrations, and ensuring WebSocket support for LiveView. This guide walks you through the entire process — from crafting a production-ready Dockerfile to deploying with a single command on Deployado.

Why Elixir Deployment is Different

If you have deployed Rails, Django, or Express apps before, you might expect to just upload your source code and run it. Elixir does not work that way. Here is what makes it unique:

  • BEAM VM requires compiled releases. In production, you do not run mix phx.server. Instead, you build a self-contained release using mix release that bundles the Erlang runtime, your compiled bytecode, and all dependencies into a single deployable artifact.
  • Asset compilation is a build step. Phoenix uses esbuild and tailwind for asset compilation. These must run during the build phase, not at runtime. Your CSS and JavaScript need to be compiled, minified, and digested before the release is created.
  • Hot code upgrades are possible (but optional). The BEAM VM supports swapping code in a running system without downtime. While most teams use rolling deploys instead, it is a unique capability of the platform that speaks to its operational maturity.
  • Database migrations use Ecto. Phoenix releases include a special release module that can run Ecto migrations without Mix. This is critical because Mix is not available in production releases.
  • LiveView needs WebSocket support. Phoenix LiveView maintains persistent WebSocket connections between the browser and server. Your hosting platform must support WebSocket upgrades, long-lived connections, and sticky sessions (or proper load balancer configuration).

The Production Dockerfile

A multi-stage Dockerfile is the standard approach for building Phoenix releases. The first stage compiles your application and builds the release, while the second stage creates a minimal runtime image. This keeps your production image small and secure.

# Stage 1: Build
# ============================================================
# Use the official Elixir image with OTP 26 on Alpine Linux
FROM elixir:1.16-otp-26-alpine AS builder

# Install build dependencies
# - build-base: gcc, make, etc. for compiling NIFs
# - git: for fetching git-based dependencies
# - npm: for asset compilation (if using npm packages)
RUN apk add --no-cache build-base git npm

# Set the build environment to production
ENV MIX_ENV=prod

# Create the application directory
WORKDIR /app

# Install Hex and Rebar (Elixir build tools)
RUN mix local.hex --force && \
    mix local.rebar --force

# Copy dependency files first (for better Docker layer caching)
# This means deps are only re-fetched when mix.exs/mix.lock change
COPY mix.exs mix.lock ./
COPY apps/my_app/mix.exs apps/my_app/mix.exs
COPY apps/my_app_web/mix.exs apps/my_app_web/mix.exs
COPY config/config.exs config/prod.exs config/runtime.exs config/

# Fetch and compile dependencies
RUN mix deps.get --only prod && \
    mix deps.compile

# Copy application source code
COPY apps/ apps/
COPY priv/ priv/
COPY rel/ rel/

# Compile assets (esbuild + tailwind)
# Phoenix 1.7+ uses esbuild and tailwind as Mix tasks
RUN cd apps/my_app_web && \
    mix assets.deploy

# Compile the application
RUN mix compile

# Build the OTP release
# This creates a self-contained release in _build/prod/rel/
RUN mix release

# Stage 2: Runtime
# ============================================================
# Use a minimal Alpine image for the production container
FROM alpine:3.19 AS runner

# Install runtime dependencies only
# - libstdc++: required by the BEAM VM
# - openssl: for SSL/TLS connections
# - ncurses-libs: for the remote console
RUN apk add --no-cache libstdc++ openssl ncurses-libs

# Set runtime environment
ENV MIX_ENV=prod
ENV PORT=4000

# Create a non-root user for security
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app

# Copy the compiled release from the builder stage
COPY --from=builder --chown=app:app /app/_build/prod/rel/my_app ./

# Switch to the non-root user
USER app

# Expose the application port
EXPOSE 4000

# Start the Phoenix server
# The release binary handles everything:
# starting the BEAM VM, loading your app, and serving requests
CMD ["bin/my_app", "start"]

A few important notes about this Dockerfile. The multi-stage approach keeps your final image under 50MB, compared to 1GB+ if you included the full Elixir/Erlang toolchain. The dependency layer caching means that subsequent builds only recompile when your mix.exs or mix.lock files change, significantly speeding up CI/CD pipelines.

Database Migrations

In development, you run mix ecto.migrate to apply database changes. But in production, Mix is not available — releases strip it out to keep the artifact lean. Phoenix solves this with a release module that you can invoke directly.

When you generate a new Phoenix project, it includes a release module at lib/my_app/release.ex:

defmodule MyApp.Release do
  @moduledoc """
  Used for executing DB release tasks when Mix is not available.
  """

  @app :my_app

  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.ensure_all_started(:ssl)
    Application.load(@app)
  end
end

You can run migrations manually by executing:

bin/my_app eval "MyApp.Release.migrate()"

With Deployado, you do not need to run migrations manually. Simply set the pre-deploy command in your app settings:

bin/my_app eval "MyApp.Release.migrate()"

Deployado runs this command inside the newly built container before routing traffic to it. If the migration fails, the deploy is aborted and your previous version continues serving requests. Zero downtime, zero risk.

Deploy with Deployado

Deployado is purpose-built for deploying containerized applications, and Phoenix apps are a perfect fit. Here is the step-by-step process to go from code to production:

Step 1: Create Your App

Use the Deployado CLI or web dashboard to create a new app. Point it at your Git repository:

# Using the Deployado CLI
deployado apps:create my-phoenix-app \
  --repo https://github.com/youruser/my-phoenix-app \
  --port 4000

Step 2: Connect a Managed PostgreSQL Database

Phoenix apps almost always need a database. Deployado provides managed PostgreSQL databases that are automatically connected to your app. The DATABASE_URL environment variable is injected automatically — no manual configuration required.

Step 3: Push Your Code

Every time you push to your configured branch, Deployado automatically builds your Docker image, runs your pre-deploy command (migrations), and deploys the new version with zero downtime.

Step 4: Blueprint (Optional)

For a fully declarative setup, you can define your entire stack in a deployado.yaml blueprint:

name: my-phoenix-app
apps:
  web:
    port: 4000
    dockerfile: Dockerfile
    env:
      SECRET_KEY_BASE: "generate-with-mix-phx-gen-secret"
      PHX_HOST: "myapp.example.com"
databases:
  db:
    name: my_phoenix_db
    connect_to: web

Run deployado blueprint:deploy and your entire stack — app, database, networking — is provisioned in one command.

Environment Variables

Phoenix apps in production rely on runtime configuration via environment variables. Here are the essential ones you need to set:

Variable Description Example
SECRET_KEY_BASE Used for signing cookies, sessions, and tokens. Generate with mix phx.gen.secret. A 64+ character random string
DATABASE_URL PostgreSQL connection string. Auto-injected by Deployado when you connect a managed database. postgres://user:pass@host/db
PHX_HOST The hostname your app is served on. Used for URL generation and WebSocket connections. myapp.example.com
PORT The port your app listens on inside the container. Phoenix defaults to 4000. 4000

Your config/runtime.exs file reads these at startup. Make sure you have the standard Phoenix runtime configuration that reads from System.get_env/1:

config :my_app, MyAppWeb.Endpoint,
  url: [host: System.get_env("PHX_HOST") || "localhost", port: 443, scheme: "https"],
  http: [port: String.to_integer(System.get_env("PORT") || "4000")],
  secret_key_base: System.fetch_env!("SECRET_KEY_BASE")

config :my_app, MyApp.Repo,
  url: System.fetch_env!("DATABASE_URL"),
  pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10")

LiveView and WebSockets

Phoenix LiveView is one of the most compelling features of the framework, enabling rich, real-time user interfaces without writing JavaScript. Under the hood, LiveView maintains a persistent WebSocket connection between each connected client and the server.

Some hosting platforms struggle with WebSocket connections — they time out connections, fail to upgrade HTTP to WebSocket, or do not properly handle the long-lived nature of these connections. Deployado fully supports WebSocket connections out of the box. No special proxy configuration, no timeouts to adjust, no headers to set. Your LiveView app just works.

If you are running multiple instances of your app behind a load balancer, keep in mind that LiveView connections are stateful. Deployado handles this by routing WebSocket connections correctly so your LiveView processes maintain their state throughout the session.

What's Next?

Once your Phoenix app is deployed, there are several additional features you can take advantage of to make your deployment production-grade:

  • Set up custom domains. Point your own domain at your Deployado app with automatic SSL certificate provisioning. No manual certificate management needed.
  • Configure auto-rollback. If a deployment fails health checks, Deployado automatically rolls back to the previous working version. Your users never see downtime.
  • Add uptime monitoring. Track your application's availability and response times. Get notified immediately if something goes wrong.
  • Set up CI/CD with GitHub integration. Connect your GitHub repository for automatic deployments on every push to your main branch. Review deploy previews on pull requests.

Ready to deploy your Phoenix app?

Start Your Free Trial

More from the blog

How to Deploy a Docker App in Minutes

Step-by-step guide to deploying Docker applications with Deployado. Infrastructure included, zero DevOps, full control.

Read more →

PaaS vs Self-Hosted: The Best of Both Worlds

Traditional PaaS means vendor lock-in. Self-hosted means DevOps overhead. Deployado eliminates the trade-offs: managed simplicity with predictable pricing.

Read more →