How to Install Elixir + Phoenix on Ubuntu 24.04 VPS: Production Release Deploy
Elixir and Phoenix are a formidable pair for building real-time web applications, APIs, and distributed systems. Running on the BEAM virtual machine (the same runtime that powers WhatsApp and Discord), they give you lightweight processes, preemptive scheduling, and fault tolerance out of the box. This guide walks you through installing Elixir and Phoenix on an Ubuntu 24.04 VPS using asdf, building a production mix release, wiring up PostgreSQL, and fronting the app with Nginx for LiveView WebSockets.
Skip the setup? Launch a pre-tuned Ubuntu 24.04 VPS in 60 seconds on our CloudCore Starter plan and start deploying Phoenix apps today.
Table of Contents
runtime.exs for ProductionWhy Elixir and Phoenix?
Elixir is a functional, dynamically typed language that compiles to BEAM bytecode and inherits three decades of battle-tested Erlang/OTP engineering. The reasons teams choose it for production web workloads are concrete:
- Massive concurrency -- The BEAM schedules millions of lightweight processes across your CPU cores. A single modest VPS can hold hundreds of thousands of stateful connections simultaneously. There is no thread-pool sizing, no event-loop starvation, no async/await colouring.
- Fault tolerance -- OTP supervisors restart crashed processes automatically in milliseconds. "Let it crash" is a real, production-viable strategy because failure is isolated to a single process, not the whole node.
- Preemptive scheduling -- Unlike Node.js or Python, one slow request cannot block every other request. The scheduler reduces every process at regular intervals, so latency stays flat under load.
- Phoenix LiveView -- Build rich, real-time user interfaces entirely in server-rendered Elixir, pushing diffs over a WebSocket. You get SPA feel with none of the client-side JavaScript complexity, and the same process model handles a hundred thousand concurrent LiveView sessions without breaking a sweat.
- First-class distribution -- Nodes connect over the distributed Erlang protocol. Global process registries, pub/sub, and clustering are library calls, not infrastructure projects.
- Batteries-included releases --
mix releaseproduces a self-contained tarball that runs anywhere with the same glibc -- no Dockerfile required, no runtime install on the target machine.
Prerequisites
Before you begin, make sure you have:
- A VPS running Ubuntu 24.04 LTS with root or sudo access
- SSH access to your server
- At least 2 GB of RAM (4 GB+ recommended if you will also run PostgreSQL on the same host)
- At least 20 GB of free disk space for the toolchain, release artifacts, and database
- A registered domain pointing to your server's IP (optional but required for SSL)
Recommended Plan: CloudCore Starter>
Phoenix apps have a small runtime footprint thanks to the BEAM. For a production app with PostgreSQL on the same host, we recommend the CloudCore Starter plan:>
- 4 vCPU cores
- 6 GB RAM
- 100 GB NVMe SSD
- Unmetered bandwidth>
That is enough headroom for tens of thousands of concurrent LiveView connections plus a local Postgres instance. For larger workloads or clustered deployments, scale up to CloudCore Professional.
Connect to your server:
ssh root@your-server-ipStep 1: Update System Packages
Start with a clean, fully patched base.
sudo apt update && sudo apt upgrade -yIf the kernel was upgraded, reboot:
sudo rebootReconnect via SSH after a minute.
Step 2: Install Build Dependencies
Erlang compiles from source when installed through asdf, so you need a C toolchain plus the libraries Erlang links against (crypto, SSL, wxWidgets for observer, ODBC, etc.). Installing the full set now avoids opaque build failures later.
sudo apt install -y build-essential autoconf m4 libncurses5-dev \
libwxgtk3.2-dev libwxgtk-webview3.2-dev libgl1-mesa-dev libglu1-mesa-dev \
libpng-dev libssh-dev unixodbc-dev xsltproc fop libxml2-utils libncurses-dev \
openjdk-21-jdk curl git unzipExplanation:
build-essential,autoconf,m4-- C compiler and build tools for Erlanglibssl-dev(pulled bybuild-essentialdeps) -- TLS support in:cryptoand:ssllibncurses-dev-- Terminal support for the Erlang shelllibwxgtk*-- Observer GUI (you can skip on headless servers, but kerl warns loudly)unixodbc-dev-- ODBC database driver bindingsopenjdk-21-jdk-- Required by some Erlang documentation targets
Step 3: Install asdf Version Manager
asdf is the recommended way to install Erlang and Elixir on Linux. It lets you pin exact versions per project, upgrade without touching system packages, and run multiple Erlang/Elixir versions side by side.
Clone asdf to your home directory and source it:
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.1
echo '. "$HOME/.asdf/asdf.sh"' >> ~/.bashrc
echo '. "$HOME/.asdf/completions/asdf.bash"' >> ~/.bashrc
source ~/.bashrcVerify the install:
asdf --versionExpected output:
v0.14.1-f00f759Add the Erlang and Elixir plugins:
asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git
asdf plugin add elixir https://github.com/asdf-vm/asdf-elixir.gitList plugins to confirm:
asdf plugin listExpected output:
elixir
erlangStep 4: Install Erlang/OTP 26 and Elixir 1.16
Phoenix 1.7 requires Erlang/OTP 24+ and Elixir 1.14+. The combination below (OTP 26.2.5 + Elixir 1.16.3 built against OTP 26) is a well-tested production pairing.
Install Erlang (this compiles from source and takes 5-10 minutes on a 4 vCPU VPS):
export KERL_CONFIGURE_OPTIONS="--disable-debug --without-javac --enable-shared-zlib --enable-dynamic-ssl-lib"
asdf install erlang 26.2.5
asdf global erlang 26.2.5Verify:
erl -eval 'io:format("~s", [erlang:system_info(otp_release)]), halt().' -noshellExpected output:
26Install Elixir (precompiled, takes seconds):
asdf install elixir 1.16.3-otp-26
asdf global elixir 1.16.3-otp-26Verify:
elixir --versionExpected output:
Erlang/OTP 26 [erts-14.2.5] [source] [64-bit] [smp:4:4] [ds:4:4:10] [async-threads:1] [jit:ns]
Elixir 1.16.3 (compiled with Erlang/OTP 26)
The pairing 1.16.3-otp-26 is important -- asdf ships Elixir precompiled against specific OTP majors, so always match to the Erlang major you installed.
Step 5: Install Hex, Rebar3, and phx_new
Hex is Elixir's package manager, Rebar3 compiles Erlang dependencies, and phx_new is the Phoenix project generator.
mix local.hex --force
mix local.rebar --force
mix archive.install hex phx_new --forceVerify:
mix phx.new --versionExpected output:
Phoenix installer v1.7.14Step 6: Install and Configure PostgreSQL
Phoenix defaults to PostgreSQL via Ecto. If you do not already have a database running, see our dedicated guide on how to install PostgreSQL on Ubuntu 24.04. The short version:
sudo apt install -y postgresql postgresql-contrib
sudo systemctl enable --now postgresqlCreate a dedicated role and database for your app (replace the password with something from openssl rand -hex 24):
sudo -u postgres psql <<EOF
CREATE USER myapp WITH PASSWORD 'CHANGE_ME_STRONG_PASSWORD';
CREATE DATABASE myapp_prod OWNER myapp;
ALTER USER myapp CREATEDB;
EOFThe CREATEDB grant is needed only if you want mix ecto.create to work for this user; production releases typically do not need it once the database exists.
Step 7: Create a Phoenix Application
Generate a new Phoenix app. We will call it myapp.
cd ~
mix phx.new myapp --database postgres
cd myappAnswer Y when asked to fetch and install dependencies.
Update config/dev.exs with your Postgres credentials, then create and migrate the dev database to confirm the stack works end to end:
mix ecto.create
mix ecto.migrate
mix phx.serverVisit http://your-server-ip:4000 (you may need to sudo ufw allow 4000 temporarily). Stop the server with Ctrl+C twice when you see the welcome page.
Step 8: Configure runtime.exs for Production
Phoenix generates config/runtime.exs specifically for production-time configuration. Unlike prod.exs, which is read at compile time, runtime.exs is evaluated at release boot -- this is where you read environment variables on the production server.
Open config/runtime.exs and confirm the :prod block contains these essentials:
if config_env() == :prod do database_url = System.get_env("DATABASE_URL") || raise "environment variable DATABASE_URL is missing"secret_key_base = System.get_env("SECRET_KEY_BASE") || raise "environment variable SECRET_KEY_BASE is missing"
host = System.get_env("PHX_HOST") || "example.com" port = String.to_integer(System.get_env("PORT") || "4000")
config :myapp, Myapp.Repo, url: database_url, pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"), socket_options: [:inet6]
config :myapp, MyappWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], http: [ip: {127, 0, 0, 1}, port: port], secret_key_base: secret_key_base, server: true end
Key points:
DATABASE_URL-- Standardpostgres://user:pass@host:port/dbconnection stringSECRET_KEY_BASE-- 64+ bytes of random data used to sign sessions and cookies. Generate one withmix phx.gen.secretPHX_HOST-- The public hostname. Used by URL helpers and for CSRF/origin checkshttp: [ip: {127, 0, 0, 1}, port: port]-- Bind to loopback only; Nginx will proxy public traffic to itserver: true-- Ensures the Cowboy/Bandit web server actually starts under the release (unlikemix phx.server, a release does not auto-start it)
Step 9: Compile Assets with esbuild and Tailwind
Modern Phoenix ships with esbuild and tailwind as Elixir-wrapped binaries -- no Node.js required on the build host.
mix assets.deployThis runs esbuild to bundle JavaScript, tailwind to generate CSS, and phx.digest to fingerprint the output into priv/static/. If you see missing-binary errors, fetch the platform binaries first:
mix esbuild.install
mix tailwind.installStep 10: Build a Mix Release
A mix release bundles your compiled BEAM files, your config, all dependencies, and optionally the Erlang runtime itself (erts) into a self-contained tarball. Booting the release does not require mix, Elixir, or Erlang to be installed on the target machine.
Set the production environment variables and build:
export MIX_ENV=prod
export SECRET_KEY_BASE=$(mix phx.gen.secret)
mix deps.get --only prod
mix compile
mix assets.deploy
mix releaseOutput ends with:
* assembling myapp-0.1.0 on MIX_ENV=prodRelease created at _build/prod/rel/myapp
- using config/runtime.exs to configure the release at runtime
# To start your system _build/prod/rel/myapp/bin/myapp start
include_erts: true or false?
In mix.exs under releases:, the include_erts option controls whether the Erlang runtime is bundled:
include_erts: true(default) -- Produces a fully self-contained release. Perfect when build host and target share a glibc version. Tarball is ~30 MB larger.include_erts: false-- Assumes Erlang is installed on the target. Use when you already manage OTP centrally (sameasdfsetup on prod) and want smaller artifacts.
true and treat the release as your unit of deployment.Running Migrations at Boot
A release does not include mix, so mix ecto.migrate does not work on the target. Use a release module that drives Ecto.Migrator directly. Create lib/myapp/release.ex:
defmodule Myapp.Release do @app :myappdef migrate do load_app() for repo <- repos() do {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) end end
defp repos do Application.fetch_env!(@app, :ecto_repos) end
defp load_app do Application.load(@app) end end
Now you can run migrations on the server with:
_build/prod/rel/myapp/bin/myapp eval "Myapp.Release.migrate()"Or invoke it automatically from the systemd unit on start, as shown in the next step.
Copying the Release
If you build on a different host (CI or local dev), copy the release tarball or the rel directory to the server:
tar -czf myapp-release.tar.gz -C _build/prod/rel myapp
scp myapp-release.tar.gz root@your-server-ip:/opt/
ssh root@your-server-ip "cd /opt && tar -xzf myapp-release.tar.gz"The extracted release lives at /opt/myapp.
Step 11: Create a systemd Service
systemd is the cleanest way to supervise a Phoenix release on Ubuntu. It restarts the node on crash, starts at boot, captures logs to journald, and lets you pass environment variables via an EnvironmentFile.
Create a dedicated user:
sudo useradd --system --home-dir /opt/myapp --shell /bin/false myapp
sudo chown -R myapp:myapp /opt/myappCreate the environment file (mode 600 because it contains secrets):
sudo tee /etc/myapp.env > /dev/null <<EOF
DATABASE_URL=postgres://myapp:CHANGE_ME_STRONG_PASSWORD@localhost:5432/myapp_prod
SECRET_KEY_BASE=$(openssl rand -base64 48 | tr -d '\n')
PHX_HOST=example.com
PORT=4000
POOL_SIZE=10
LANG=en_US.UTF-8
EOF
sudo chmod 600 /etc/myapp.envCreate the unit file at /etc/systemd/system/myapp.service:
sudo tee /etc/systemd/system/myapp.service > /dev/null <<'EOF' [Unit] Description=Myapp Phoenix Release After=network.target postgresql.service Requires=postgresql.service[Service] Type=exec User=myapp Group=myapp WorkingDirectory=/opt/myapp EnvironmentFile=/etc/myapp.env ExecStartPre=/opt/myapp/bin/myapp eval "Myapp.Release.migrate()" ExecStart=/opt/myapp/bin/myapp start ExecStop=/opt/myapp/bin/myapp stop Restart=on-failure RestartSec=5 LimitNOFILE=65535 KillMode=process
[Install] WantedBy=multi-user.target EOF
Key choices:
Type=exec-- systemd considers the service started once the main process is exec'd. Works well withbin/myapp start(which does not fork).ExecStartPre-- Runs migrations idempotently on every start. If there is nothing to migrate, it is a fast no-op.LimitNOFILE=65535-- BEAM opens one file descriptor per socket. Raise the limit or you will hit it at a few thousand concurrent connections.Restart=on-failure-- systemd restarts the release if it crashes. OTP supervisors handle in-process failures; systemd is the last line of defence.
sudo systemctl daemon-reload
sudo systemctl enable --now myapp
sudo systemctl status myappExpected output:
● myapp.service - Myapp Phoenix Release
Loaded: loaded (/etc/systemd/system/myapp.service; enabled; preset: enabled)
Active: active (running) since Wed 2026-04-16 10:00:00 UTC; 10s ago
Main PID: 12345 (beam.smp)Tail logs with sudo journalctl -u myapp -f.
Step 12: Nginx Reverse Proxy with WebSocket Upgrade
Phoenix LiveView uses a persistent WebSocket. Nginx must be told explicitly to forward the Upgrade and Connection headers, otherwise the handshake will 400 and LiveView will fall back to full-page reloads.
For the full Nginx install walkthrough, see our how to install Nginx on Ubuntu 24.04 guide. The Phoenix-specific vhost looks like this:
sudo tee /etc/nginx/sites-available/myapp > /dev/null <<'EOF' map $http_upgrade $connection_upgrade { default upgrade; '' close; }upstream phoenix_upstream { 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;
client_max_body_size 20m;
# LiveView and channels WebSocket location /live/websocket { proxy_pass http://phoenix_upstream; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $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 86400s; proxy_send_timeout 86400s; }
# Everything else location / { proxy_pass http://phoenix_upstream; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $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 60s; } } EOF
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/ sudo nginx -t && sudo systemctl reload nginx
The critical pieces:
map $http_upgrade $connection_upgrade-- Synthesises the rightConnection: upgradeheader only when the client actually requests an upgrade, avoiding interference with keep-alive on normal HTTP requests.proxy_http_version 1.1-- Required for WebSockets; HTTP/1.0 does not support the upgrade mechanism.proxy_read_timeout 86400son/live/websocket-- A day's worth of idle tolerance so LiveView connections are not killed by default 60-second proxy timeouts.upstream ... keepalive 32-- Reuses connections to the BEAM, cutting TCP handshake overhead on busy sites.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.comVisit https://example.com -- your Phoenix app should load with a secure padlock and LiveView should stay connected (check the browser devtools Network tab for a WebSocket in 101 Switching Protocols).
Post-Install: Hot-Code Deploy and Clustering
Hot-Code Upgrades with Release Overlays
One of OTP's headline features is appup-based hot upgrades -- swapping modules in a running node without dropping connections. Phoenix mix releases support this via :appup and release overlays, but in practice most teams deploy by rolling the systemd service:
sudo systemctl restart myappOTP's graceful shutdown gives in-flight requests a short grace period. Combined with Nginx retries, a rolling restart is usually invisible to users.
If you genuinely need zero-downtime hot upgrades (rare, and worth the complexity only for always-on stateful systems), use Distillery or hand-author the appup with mix release --upgrade. Start from the Elixir release documentation before committing to this path.
Clustering with libcluster
To run Phoenix on multiple nodes (for redundancy, horizontal scale, or LiveView presence across regions), add libcluster:
# mix.exs
{:libcluster, "~> 3.3"}# config/runtime.exs
config :libcluster,
topologies: [
gossip: [
strategy: Cluster.Strategy.Gossip,
config: [
port: 45892,
if_addr: "0.0.0.0",
multicast_addr: "230.1.1.251",
broadcast_only: true
]
]
]And supervise it in application.ex:
{Cluster.Supervisor, [Application.get_env(:libcluster, :topologies), [name: Myapp.ClusterSupervisor]]}Set RELEASE_COOKIE to the same value on every node and open UDP 45892 between them. Nodes will discover each other automatically and Node.list/0 will show the cluster membership. For multi-region deploys, swap the Gossip strategy for Kubernetes, EPMD, or ErlangHosts as appropriate.
Once clustered, Phoenix PubSub and Presence work across nodes transparently -- a LiveView on node A will receive updates broadcast from node B with no code changes.
CRDTs for Shared State
When nodes need eventually consistent shared state (distributed counters, session stores, rate limiters), reach for delta_crdt or Horde. Horde in particular gives you a distributed process registry and dynamic supervisor that survives network partitions -- the BEAM-native equivalent of a service mesh sidecar.
Troubleshooting
| Problem | Cause | Solution |
|---|---|---|
Error loading module ... : undefined function on release start | include_erts: false on a target without matching OTP | Rebuild with include_erts: true, or install the exact OTP major version on the target |
cannot execute: required file not found running bin/myapp | Missing C runtime libs on target (libstdc++, libtinfo) | sudo apt install -y libtinfo6 libstdc++6 on the target matches the build host |
** (Postgrex.Error) FATAL 28P01 password authentication failed | Wrong DATABASE_URL password or pg_hba.conf policy | Verify with psql "$DATABASE_URL". Check /etc/postgresql/16/main/pg_hba.conf for scram-sha-256 vs md5 |
eaddrinuse or :eaddrinuse on port 4000 | Another Phoenix instance (or leftover iex) is bound | sudo lsof -i :4000 then kill. Check for a running mix phx.server from dev |
| LiveView reconnects constantly, "socket closed" in JS console | Nginx missing WebSocket upgrade headers | Confirm map $http_upgrade block and proxy_set_header Upgrade on /live/websocket |
Nodes cannot see each other (Node.list() == []) | Cookie mismatch or firewall blocking EPMD/gossip | Same RELEASE_COOKIE on every node; open TCP 4369 (EPMD) and UDP 45892 (gossip) between them |
:logger handler crashed or :too_many_open_files | LimitNOFILE too low | Raise to 65535 in the systemd unit and sudo systemctl daemon-reload |
| Migrations "stuck" on deploy | Concurrent migration from two nodes | Migrate on one node only, or use Ecto.Migrator lock. Consider a dedicated one-shot systemd unit for migrations |
Reading Logs
Everything the release prints goes to journald via systemd:
sudo journalctl -u myapp -f # live tail
sudo journalctl -u myapp -n 200 # last 200 lines
sudo journalctl -u myapp --since "1 hour ago"To attach a remote iex shell to the running node for live inspection:
sudo -u myapp /opt/myapp/bin/myapp remoteYou now have a full iex prompt inside the production BEAM. Use :observer.start() (over X11-forwarded SSH) or :recon for process-level diagnostics.
Next Steps
Your Phoenix app is running on a production Ubuntu VPS with PostgreSQL, a mix release under systemd, and Nginx fronting LiveView WebSockets. Recommended follow-ups:
- Add background jobs with Oban -- Oban is the de facto job queue for Elixir. Uses your existing Postgres, gives you cron, retries, rate limiting, and a web dashboard.
- Wire up observability -- Add PromEx for Prometheus metrics and ship them to Grafana. BEAM telemetry is rich: per-process memory, scheduler utilisation, Ecto query timings, Phoenix endpoint stats.
- Automate deploys -- Script the release build + scp + systemctl restart flow, or adopt Kamal / flyctl for declarative deploys.
- Scale horizontally -- Once you have libcluster running, add a second CloudCore node behind the same Nginx (or a load balancer) and let Phoenix PubSub spread events across the cluster.
- Explore the Phoenix docs -- The official Phoenix Framework guides and the Elixir language docs are both excellent and worth reading end to end.
Skip the Manual Install -- Launch Elixir-Ready VPS>
Every CloudCore Starter plan ships with Ubuntu 24.04, NVMe storage, and the networking headroom your Phoenix LiveView workload needs. Deploy a node in 60 seconds and have your first mix release running in under an hour.>
- 4 vCPU, 6 GB RAM, 100 GB NVMe
- Unmetered bandwidth for LiveView WebSockets
- Full root access and cloud-init support
- EU and North American datacentre choice>
Launch Your CloudCore Starter VPS -- production-grade Phoenix hosting from day one.