Deploy Phoenix to Production: A Practical Guide

Khimananda Oli 9 min read Programming and Languages
Deploy Phoenix to Production: A Practical Guide

By Khimananda Oli | Last reviewed: August 2026

You have built a high-performance application in Elixir, but running it locally differs vastly from operating it reliably on a server. To successfully deploy Phoenix to production, you must compile an optimized Erlang release, configure a reverse proxy for TLS termination, and manage the runtime as a system service. This guide walks through the exact infrastructure patterns I use for client projects, moving beyond basic tutorials to cover the operational realities of hosting Elixir applications on Linux VPS environments. If you are preparing your underlying database layer as well, review my notes on PostgreSQL administration essentials before provisioning.

Internet TrafficNginx Reverse ProxyTLS TerminationStatic AssetsPhoenix ReleaseSystemd ManagedPort 4000 (Internal)PostgreSQL / Redis
Standard topology when you deploy Phoenix to production: Nginx handles public traffic while the Elixir release runs internally managed by systemd.

How do you prepare an Elixir release for production?

A common mistake developers make when they first deploy Phoenix to production is attempting to run the application using mix phx.server. This starts the Mix build tool in a persistent runtime state, which consumes excess memory, includes development dependencies, and lacks proper signal handling. In practice, you must generate a self-contained Erlang release that bundles the BEAM VM, your application code, and all runtime dependencies into a single immutable artifact.

Configure the release definition

Open your mix.exs file and define a release block within the project function. This configuration tells the compiler how to package the application and which steps to execute during boot.

def project do
  [
    app: :my_app,
    version: "0.1.0",
    elixir: "~> 1.17",
    releases: [
      my_app: [
        include_executables_for: [:unix],
        applications: [runtime_tools: :permanent],
        steps: [:assemble, :tar]
      ]
    ]
  ]
end

The include_executables_for: [:unix] directive generates the necessary shell scripts for Linux environments. The runtime_tools application is mandatory; without it, you cannot attach remote consoles or perform live debugging during incidents. Always set your environment variables before assembly:

MIX_ENV=prod mix deps.get --only prod
MIX_ENV=prod mix compile
MIX_ENV=prod mix assets.deploy
MIX_ENV=prod mix release

This sequence compiles assets, digests them with unique hashes for cache busting, and produces a tarball in _build/prod/my_app-0.1.0.tar.gz. Extract this archive on your target server or inside your final Docker stage. Never copy source code to production servers; only the assembled release artifact should cross the boundary between CI and runtime environments.

How do you configure Nginx as a reverse proxy for Phoenix?

Elixir's Bandit or Cowboy HTTP servers are excellent at handling concurrent connections, but they are not designed to terminate TLS directly in most production setups. When you deploy Phoenix to production, place Nginx in front to handle SSL certificates, gzip compression, and static file serving. This separation allows the BEAM VM to focus exclusively on application logic while Nginx manages the slow, messy parts of internet traffic.

Nginx virtual host configuration

Create a configuration file at /etc/nginx/sites-available/phoenix-app. This example assumes your Phoenix release listens on localhost port 4000 and you have obtained certificates via Let's Encrypt. For detailed certificate management, see my guide on setting up free SSL with Let's Encrypt.

upstream phoenix_backend {
    server 127.0.0.1:4000;
    keepalive 32;
}

server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    root /opt/my_app/lib/my_app-0.1.0/priv/static;

    location /assets/ {
        expires max;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    location / {
        proxy_pass http://phoenix_backend;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 86400;
    }
}

The keepalive 32 directive in the upstream block maintains persistent connections between Nginx and Phoenix, eliminating TCP handshake overhead for every request. The WebSocket headers (Upgrade and Connection) are critical if your application uses LiveView or channels; omitting them will cause real-time features to silently fail. After configuring, test with nginx -t and reload with systemctl reload nginx.

Client BrowserNginx ProxyPhoenix AppHTTPS RequestHTTP (Keepalive)Response + HeadersHTTPS ResponseStatic FilesServed Directly
Request lifecycle after you deploy Phoenix to production: Nginx terminates TLS and serves static assets without hitting the BEAM VM.

How do you manage Phoenix as a systemd service?

Once the release is extracted and Nginx is configured, you need a process manager to keep the application running across reboots and crashes. Systemd is the standard choice on Ubuntu and Debian systems. It provides automatic restarts, log journaling, resource limits, and dependency ordering — all essential when you deploy Phoenix to production in any serious capacity.

Create the service unit file

Create /etc/systemd/system/phoenix-app.service with the following content. Adjust paths and usernames to match your server setup.

[Unit]
Description=Phoenix Application Service
After=network.target postgresql.service
Wants=postgresql.service

[Service]
Type=simple
User=www-data
Group=www-data
WorkingDirectory=/opt/my_app
EnvironmentFile=/opt/my_app/.env
ExecStart=/opt/my_app/bin/my_app start
ExecStop=/opt/my_app/bin/my_app stop
Restart=on-failure
RestartSec=5
LimitNOFILE=65535

[Install]
WantedBy=multi-user.target

Several details here matter enormously. The EnvironmentFile directive loads secrets from a file with restricted permissions (chmod 600) rather than embedding them in the unit file where they might leak into process listings. The LimitNOFILE=65535 setting raises the open file descriptor limit; Phoenix applications handling many concurrent connections will hit the default 1024 limit quickly. The Restart=on-failure policy ensures the BEAM VM comes back automatically after crashes, but not after intentional stops via bin/my_app stop.

Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable phoenix-app
sudo systemctl start phoenix-app
sudo journalctl -u phoenix-app -f

Monitor the journal output during initial startup. If the application fails to boot, check for missing environment variables, database connectivity issues, or permission errors on the working directory. For comprehensive server hardening before going live, reference the Ubuntu security hardening guide.

What are the key differences between Docker and native releases?

Teams often debate whether to containerize or use bare-metal releases when they deploy Phoenix to production. Both approaches work, but they optimize for different operational constraints. Understanding these trade-offs prevents costly infrastructure pivots later.

CriteriaNative Elixir ReleaseDocker Container
Startup TimeInstant (no container runtime overhead)Slight delay (container init + namespace setup)
Resource OverheadMinimal (direct kernel access)Low but measurable (cgroups, overlay filesystem)
Deployment ConsistencyRequires identical OS/library versionsGuaranteed identical environment everywhere
Secret ManagementEnvironment files or Vault agentDocker secrets, Kubernetes secrets, or Vault
Rollback SpeedExtract previous tarball, restart serviceChange image tag, redeploy container
Best ForSingle-server VPS, Nepal-based low-latency deploymentsKubernetes clusters, multi-region scaling, CI-heavy teams

In my experience, native releases excel for single-server deployments where simplicity and raw performance matter most. Docker becomes mandatory once you need horizontal scaling, consistent staging-to-production parity, or orchestration via Kubernetes. Neither approach is universally superior; choose based on your team's operational maturity and scaling requirements.

Native Release PathCI: mix release → tar.gz artifactDeploy: scp + extract + systemd restartRuntime: Direct BEAM on host OSDocker Container PathCI: Build image → Push to registryDeploy: Pull image + orchestrate podsRuntime: BEAM inside container namespace
Two valid paths to deploy Phoenix to production: choose native releases for simplicity or Docker for portability and scale.

How do you handle secrets and runtime configuration safely?

Never compile secrets into your release artifact. Database passwords, API keys, and encryption salts must be injected at runtime. When you deploy Phoenix to production, use environment variables loaded from a protected file or a secrets manager. The config/runtime.exs file is specifically designed for this pattern in modern Phoenix versions.

# config/runtime.exs
import Config

if config_env() == :prod do
  database_url =
    System.get_env("DATABASE_URL") ||
      raise """
      environment variable DATABASE_URL is missing.
      For example: ecto://USER:PASS@HOST/DATABASE
      """

  config :my_app, MyApp.Repo,
    url: database_url,
    pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
    socket_options: [:binary, active: false]

  secret_key_base =
    System.get_env("SECRET_KEY_BASE") ||
      raise "SECRET_KEY_BASE not set"

  config :my_app, MyAppWeb.Endpoint,
    http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT") || "4000")],
    secret_key_base: secret_key_base,
    server: true
end

This configuration executes at boot time, not compile time. It reads values from the environment and raises immediately if required variables are missing, preventing silent failures. Store the actual values in /opt/my_app/.env with permissions 600 owned by the application user. For larger teams or compliance-regulated environments like SOC 2, integrate HashiCorp Vault or AWS Secrets Manager instead of flat files. Observability also matters here; ensure your structured logging captures configuration validation outcomes without leaking secret values. My article on structured logging best practices covers safe instrumentation patterns for Elixir applications.

Ready to Ship Your Phoenix Application?

When you deploy Phoenix to production correctly, you gain an application that boots in milliseconds, handles thousands of concurrent connections per core, and recovers gracefully from failures. The combination of Elixir releases, Nginx reverse proxying, systemd process management, and disciplined secret handling forms a battle-tested foundation that scales from single-server startups to distributed enterprise systems. Do not skip the observability layer; instrument your application before launch, not after the first incident.

If your team needs hands-on support architecting Elixir infrastructure, hardening deployment pipelines, or preparing for compliance audits, reach out through my contact page. I help organizations build production systems that survive traffic spikes and pass security reviews without last-minute panic.

Frequently Asked Questions

Use Fly.io or Gigalixir for managed Elixir deployments. Both handle BEAM clustering, SSL termination, and zero-downtime releases natively without custom Docker orchestration overhead.

Store secrets in AWS Secrets Manager or Vault, inject via runtime providers like Vapor. Never commit .env files; use release config to fetch values at boot time only.

No. Bandit and Cowboy handle TLS and HTTP/2 directly. Nginx adds latency unless you need static asset caching or WAF rules that BEAM cannot provide efficiently.

Start with pool_size equal to CPU cores times two. Monitor checkout times with Telemetry; increase only if queue latency exceeds 50ms during peak load.

Terminate TLS at your load balancer or use Bandit’s built-in certificate handling. Configure HSTS headers and OCSP stapling in your endpoint config for full compliance.

Yes, but expect complexity. Use k8s operators like Brevity for BEAM-aware scheduling. Standard HPA fails because Erlang schedulers saturate before CPU metrics trigger scaling events.

Migrations run inside release tasks lack runtime config. Ensure Ecto.Repo connects using system environment variables, not compile-time config, when executing eval commands in containers.

Instrument with OpenTelemetry and export to Grafana Cloud. Track VM memory, message queue length, and request latency percentiles to detect BEAM-specific bottlenecks early.

Unbounded GenServer mailboxes or large binary references prevent garbage collection. Use Recon or Observer to inspect process heap sizes and identify leaking stateful processes immediately.

Not strictly. Distillery or Mix releases produce self-contained binaries. Containers simplify CI consistency but add layer overhead; bare-metal releases work fine for single-server setups.

Use rolling updates with health checks hitting /api/health. Ensure new nodes join the cluster and pass readiness probes before old pods terminate gracefully.

Structured JSON via LoggerJSON. Include trace_id, request_id, and metadata fields. Avoid plain text logs; they break parsing in Datadog or Loki pipelines.

Similar compute costs, lower memory per request. BEAM concurrency reduces server count for WebSocket-heavy apps. Expect $20–$50 monthly on Fly.io for moderate traffic.

Only for external pub/sub or job queues. Phoenix PubSub uses PG2 or Gossip internally. Adding Redis introduces network hops and failure domains unnecessarily for most cases.

Check security groups, DNS resolution, and TCP keepalive settings. Use :inet.gethostbyname and telnet from the container shell to isolate network versus application layer failures.