
Table of Contents
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.
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.
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.
| Criteria | Native Elixir Release | Docker Container |
|---|---|---|
| Startup Time | Instant (no container runtime overhead) | Slight delay (container init + namespace setup) |
| Resource Overhead | Minimal (direct kernel access) | Low but measurable (cgroups, overlay filesystem) |
| Deployment Consistency | Requires identical OS/library versions | Guaranteed identical environment everywhere |
| Secret Management | Environment files or Vault agent | Docker secrets, Kubernetes secrets, or Vault |
| Rollback Speed | Extract previous tarball, restart service | Change image tag, redeploy container |
| Best For | Single-server VPS, Nepal-based low-latency deployments | Kubernetes 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.
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.