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 usingmix releasethat 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.