The Complete Guide to Deploying Elixir/Phoenix on a Single VPS
Deploying a Phoenix application doesn't require Kubernetes, Docker, or complex cloud infrastructure. A single VPS with PostgreSQL, Nginx, and a Phoenix release is a production-ready setup that can handle thousands of concurrent users.
This guide walks through every step of deploying an Elixir/Phoenix application with PostgreSQL on a single Linux server. We'll cover server provisioning, database setup, building releases, process management with systemd, reverse proxying with Nginx, SSL certificates, and troubleshooting.
What We're Building
Internet Traffic
│
▼
┌─────────────┐
│ Nginx │ Port 80/443 — SSL termination, reverse proxy
└──────┬──────┘
│ proxy_pass → localhost:4000
▼
┌─────────────┐
│ Phoenix │ Port 4000 — Your Elixir app (OTP release)
│ Release │ LiveView WebSockets, HTTP requests
└──────┬──────┘
│ Ecto connection pool
▼
┌─────────────┐
│ PostgreSQL │ Database on the same machine
└─────────────┘
All three components run on the same server. This architecture handles moderate traffic (hundreds of concurrent users) comfortably and costs $5-20/month.
Prerequisites
- A VPS running Ubuntu 22.04+ (any provider: Hetzner, DigitalOcean, Vultr, Linode, AWS Lightsail)
- A domain name pointed to your server's IP (for SSL)
- Your Phoenix project using Ecto with PostgreSQL
- SSH access to the server as root
Recommended Server Specs
| Workload | CPU | RAM | Disk | Monthly Cost |
|---|---|---|---|---|
| Side project | 1 vCPU | 2 GB | 20 GB SSD | $5-10 |
| Production app | 2 vCPU | 4 GB | 40 GB NVMe | $10-20 |
| High traffic | 4 vCPU | 8 GB | 80 GB NVMe | $30-50 |
Phoenix is extremely memory-efficient. Each LiveView connection uses ~50KB of RAM, so 4 GB of RAM can handle thousands of concurrent WebSocket connections.
Step 1: Initial Server Setup
SSH into your freshly provisioned server:
ssh root@YOUR_SERVER_IP
1.1 Update the System
First, update all packages to ensure security patches are applied:
apt update && apt upgrade -y
What this does: apt update refreshes the package index (the list of available packages and versions). apt upgrade installs newer versions of all currently installed packages. The -y flag auto-confirms prompts.
1.2 Install Build Dependencies
Phoenix releases need to be compiled on the server (or cross-compiled). Install the essential build tools:
apt install -y build-essential git curl wget unzip \
libssl-dev automake autoconf libncurses5-dev \
nginx gnupg2 lsb-release dnsutils
What each package does:
| Package | Purpose |
|---|---|
build-essential |
C compiler, make, and standard build tools. Required for compiling Erlang from source and any NIFs (Native Implemented Functions) your deps use. |
git |
Version control. Needed by asdf and potentially your deploy process. |
libssl-dev |
OpenSSL development headers. Erlang's crypto module requires this. |
libncurses5-dev |
Terminal handling library. Required by Erlang's interactive shell. |
nginx |
The reverse proxy that sits in front of Phoenix, handles SSL, and proxies requests. |
dnsutils |
Provides the dig command for DNS verification. |
Step 2: Install PostgreSQL
2.1 Add the Official PostgreSQL Repository
Ubuntu's default repositories ship older PostgreSQL versions. We'll use the official PostgreSQL repository to get the latest stable release:
sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc | gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg
apt update
What this does:
- Adds the PostgreSQL APT repository for your Ubuntu version
- Downloads and installs the PostgreSQL GPG signing key (so apt trusts packages from this repo)
- Refreshes the package list to include the new repository
2.2 Install and Start PostgreSQL
apt install -y postgresql-17 postgresql-contrib-17
systemctl enable postgresql
systemctl start postgresql
What this does:
postgresql-17installs the PostgreSQL 17 serverpostgresql-contrib-17installs additional modules (likecitextfor case-insensitive text,pg_trgmfor fuzzy matching)systemctl enableconfigures PostgreSQL to start automatically on server bootsystemctl startstarts the service immediately
Verify it's running:
systemctl status postgresql
# Should show "active (running)"
sudo -u postgres psql -c "SELECT version();"
# Should print the PostgreSQL version
2.3 Create Your Application Database
Create a dedicated database user and database for your app. Never use the postgres superuser for your application — it has unrestricted access to everything.
sudo -u postgres psql << 'EOF'
CREATE USER myapp_user WITH PASSWORD 'a_strong_password_here';
CREATE DATABASE myapp_prod OWNER myapp_user;
\c myapp_prod
CREATE EXTENSION IF NOT EXISTS citext;
GRANT CREATE ON SCHEMA public TO myapp_user;
EOF
What each command does:
| Command | Purpose |
|---|---|
CREATE USER |
Creates a PostgreSQL role with login capability and a password |
CREATE DATABASE ... OWNER |
Creates the database and makes your app user the owner |
\c myapp_prod |
Connects to the new database (extensions must be created inside the target database) |
CREATE EXTENSION citext |
Enables case-insensitive text type (common for email columns) |
GRANT CREATE ON SCHEMA public |
Allows the app user to create tables via Ecto migrations |
Choose a strong password. You can generate one with:
openssl rand -base64 32
2.4 Tune PostgreSQL for Your Server
PostgreSQL's default configuration is conservative. Tune it based on your available RAM:
nano /etc/postgresql/17/main/postgresql.conf
Add or modify these settings at the end of the file:
# Memory — adjust based on your server's RAM
shared_buffers = 512MB # 25% of total RAM (for 2-4 GB servers)
effective_cache_size = 2GB # 50-75% of total RAM
work_mem = 4MB # Per-operation sort memory
maintenance_work_mem = 128MB # Memory for VACUUM, CREATE INDEX
# Connections
max_connections = 50 # Match your Ecto pool_size + some headroom
# Write-ahead log
wal_buffers = 16MB # Good default for most workloads
What each setting does:
shared_buffers— PostgreSQL's main memory cache. This is where frequently accessed data is kept in RAM. Set to about 25% of total RAM.effective_cache_size— A hint to the query planner about how much memory is available for caching (including OS page cache). Set to 50-75% of RAM.work_mem— Memory allocated per sort/hash operation within a single query. Keep this low (4-8MB) because each query can use multiple sort operations simultaneously.maintenance_work_mem— Memory for maintenance operations like VACUUM and CREATE INDEX. Can be higher since these run less frequently.max_connections— Maximum number of simultaneous database connections. Your Ectopool_size(typically 10-20) plus room for admin connections and monitoring.
Restart PostgreSQL to apply:
systemctl restart postgresql
Step 3: Install Erlang, Elixir, and Node.js
We use asdf to manage runtime versions. This ensures your server uses the exact same versions as your development machine.
3.1 Install asdf Version Manager
git clone https://github.com/asdf-vm/asdf.git ~/.asdf --branch v0.14.0
echo '. "$HOME/.asdf/asdf.sh"' >> ~/.bashrc
source ~/.bashrc
What this does: Clones the asdf repository into ~/.asdf, adds it to your shell's startup script, and loads it into the current session. After this, the asdf command is available.
Verify: asdf --version should print the version number.
3.2 Install Erlang
Erlang must be compiled from source on the server. This takes 10-15 minutes on a 2-core machine.
asdf plugin add erlang
# Build dependencies (skip GUI libraries — not needed on a server)
apt install -y libpng-dev libssh-dev unixodbc-dev xsltproc fop
# Compile Erlang without GUI support
KERL_CONFIGURE_OPTIONS="--without-wx --without-javac --without-odbc" \
asdf install erlang 26.0.2
asdf global erlang 26.0.2
What the flags mean:
--without-wx— Skip the wxWidgets GUI toolkit (saves compile time, not needed on a server)--without-javac— Skip the Java interface--without-odbc— Skip the ODBC database driver (we use Postgrex, not ODBC)
Important: Match the Erlang version to what your project was compiled with. Check your dev machine with erl -noshell -eval 'io:format("~s", [erlang:system_info(otp_release)]), halt().'
If you see a lock file error:
rm -rf ~/.asdf/plugins/erlang/kerl-home/builds/asdf_26.0.2
# Then retry the install
3.3 Install Elixir
asdf plugin add elixir
asdf install elixir 1.18.4-otp-26
asdf global elixir 1.18.4-otp-26
The -otp-26 suffix ensures Elixir is compiled for Erlang/OTP 26. This must match your Erlang version.
Verify:
elixir --version
# Should show: Elixir X.Y.Z (compiled with Erlang/OTP 26)
3.4 Install Node.js
Node.js is needed for JavaScript asset compilation (esbuild, Tailwind, npm packages):
asdf plugin add nodejs
asdf install nodejs 18.20.0
asdf global nodejs 18.20.0
Verify: node --version should print v18.20.0.
Step 4: Create a Deploy User
Running your application as root is a security risk. Create a dedicated user:
useradd -m -s /bin/bash deploy
mkdir -p /home/deploy/app
What this does:
- Creates a user called
deploywith a home directory (-m) and bash shell (-s /bin/bash) - Creates the directory where the release will live
Share asdf with the deploy user:
cp -r ~/.asdf /home/deploy/.asdf
chown -R deploy:deploy /home/deploy/
echo '. "$HOME/.asdf/asdf.sh"' >> /home/deploy/.bashrc
Allow the deploy user to restart the app service:
echo "deploy ALL=(ALL) NOPASSWD: /bin/systemctl restart myapp, /bin/systemctl stop myapp, /bin/systemctl start myapp, /bin/systemctl status myapp" > /etc/sudoers.d/deploy
chmod 440 /etc/sudoers.d/deploy
This grants passwordless sudo for only the systemctl commands needed for deployment. The deploy user cannot do anything else as root.
Step 5: Configure Environment Variables
Create an environment file that systemd will load when starting your app:
nano /home/deploy/.env
Contents — no export keyword, no quotes around values:
DATABASE_URL=ecto://myapp_user:a_strong_password_here@localhost/myapp_prod
SECRET_KEY_BASE=your_64_char_secret_key_here
PHX_HOST=yourdomain.com
PHX_SCHEME=https
PHX_SERVER=true
PORT=4000
Generate SECRET_KEY_BASE on the server:
cd /home/deploy/myapp
mix phx.gen.secret
# Copy the output and paste it as the SECRET_KEY_BASE value
Critical format rules:
DATABASE_URL=value— no spaces around=- No
exportprefix — systemd'sEnvironmentFiledoesn't support it - No quotes —
SECRET_KEY_BASE=abc123notSECRET_KEY_BASE="abc123" - No trailing spaces or invisible characters
Secure the file:
chown deploy:deploy /home/deploy/.env
chmod 600 /home/deploy/.env
chmod 600 means only the deploy user can read the file. This protects your database password and secret key.
Step 6: Upload and Build Your Application
6.1 Create a Tarball (on your local machine)
cd /path/to/your/project
# Clean up macOS metadata files if on Mac
find . -name '._*' -delete 2>/dev/null
find . -name '.DS_Store' -delete 2>/dev/null
# Create tarball excluding build artifacts
tar czf /tmp/myapp.tar.gz \
--exclude=_build --exclude=deps --exclude=node_modules \
--exclude=.git --exclude=priv/static/assets \
--exclude='._*' --exclude='.DS_Store' \
-C /path/to/parent_directory myapp
What we exclude and why:
_build— Compiled BEAM files. Will be recompiled on the server for the target architecture.deps— Hex packages. Will be re-fetched on the server.node_modules— npm packages. Will be reinstalled on the server..git— Git history. Not needed for deployment.._*— macOS resource fork files. These cause content loading errors on Linux.
6.2 Upload to the Server
scp /tmp/myapp.tar.gz root@YOUR_SERVER_IP:/home/deploy/
6.3 Extract and Build
ssh root@YOUR_SERVER_IP
cd /home/deploy
rm -rf myapp # Remove old source if exists
tar xzf myapp.tar.gz
chown -R deploy:deploy myapp
cd myapp
6.4 Build the Release
# Source environment variables (needed during release build)
source /home/deploy/.env
# Install Elixir package managers
mix local.hex --force
mix local.rebar --force
# Fetch Elixir dependencies (production only)
MIX_ENV=prod mix deps.get --only prod
# Install JavaScript dependencies
cd assets && npm install && cd ..
# Compile and minify CSS/JS assets
MIX_ENV=prod mix assets.deploy
# Clean compile all Elixir code
rm -rf _build/prod
MIX_ENV=prod mix compile --force
# Run database migrations
MIX_ENV=prod mix ecto.migrate
# Build the release
MIX_ENV=prod mix release --overwrite
# Copy release to the app directory
rm -rf /home/deploy/app/myapp
cp -r _build/prod/rel/myapp /home/deploy/app/
chown -R deploy:deploy /home/deploy/app/
Why rm -rf _build/prod before compile?
This is critical. Phoenix releases embed runtime.exs configuration during the build. If you don't clean the build directory, stale configuration from previous builds can get baked into the release. This is a common source of "my env vars aren't working" bugs.
Why source .env before building?
The runtime.exs file in your Phoenix project calls System.get_env() during the release build. If environment variables aren't set, those config values get baked in as nil.
6.5 Test the Release
source /home/deploy/.env
/home/deploy/app/myapp/bin/myapp start_iex
You should see the application boot. Press Ctrl+C twice to exit. If it crashes, check the error message — common issues:
"Database connection refused"— PostgreSQL isn't running or DATABASE_URL is wrong"SECRET_KEY_BASE is missing"— .env file not sourced or has wrong format
Step 7: Configure systemd
systemd manages your Phoenix application as a background service. It starts the app on boot, restarts it on crash, and captures logs.
7.1 Create the Service File
nano /etc/systemd/system/myapp.service
Contents:
[Unit]
Description=My Phoenix Application
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=exec
User=deploy
Group=deploy
WorkingDirectory=/home/deploy/app/myapp
EnvironmentFile=/home/deploy/.env
ExecStart=/home/deploy/app/myapp/bin/myapp start
ExecStop=/home/deploy/app/myapp/bin/myapp stop
Restart=on-failure
RestartSec=5
SyslogIdentifier=myapp
[Install]
WantedBy=multi-user.target
What each directive does:
| Directive | Purpose |
|---|---|
After=postgresql.service |
Wait for PostgreSQL to start before launching the app |
Requires=postgresql.service |
Fail to start if PostgreSQL isn't running |
User=deploy |
Run the process as the deploy user, not root |
EnvironmentFile |
Load environment variables from the .env file |
Restart=on-failure |
Automatically restart if the app crashes (exit code != 0) |
RestartSec=5 |
Wait 5 seconds before restarting (prevents rapid restart loops) |
SyslogIdentifier |
Tag log entries with this name for easy filtering |
7.2 Enable and Start
systemctl daemon-reload # Reload systemd to pick up the new service file
systemctl enable myapp # Start on boot
systemctl start myapp # Start now
7.3 Verify
systemctl status myapp
Should show active (running). If it shows failed, check the logs:
journalctl -u myapp -n 50 --no-pager
Common systemd errors:
| Error | Cause | Fix |
|---|---|---|
Failed at step EXEC: No such file or directory |
Release binary not at the path in ExecStart | Verify the path: ls /home/deploy/app/myapp/bin/myapp |
exit-code, status=203/EXEC |
Same as above, or wrong permissions | chown -R deploy:deploy /home/deploy/app/ |
exit-code, status=1 |
App crashed on start | Check journalctl -u myapp -n 50 for the Elixir error |
Step 8: Configure Nginx Reverse Proxy
Nginx sits in front of Phoenix for several reasons:
- SSL termination — handles HTTPS certificates
- Static file serving — serves CSS/JS/images directly (faster than going through Phoenix)
- WebSocket proxying — forwards LiveView WebSocket connections
- Security — hides the application server from direct internet access
8.1 Create the Nginx Configuration
nano /etc/nginx/sites-available/myapp
Contents:
upstream phoenix {
server 127.0.0.1:4000;
}
server {
listen 80;
server_name yourdomain.com;
# WebSocket support for LiveView
location /live/ {
proxy_pass http://phoenix;
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;
}
# Everything else
location / {
proxy_pass http://phoenix;
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;
}
}
Key directives explained:
proxy_http_version 1.1+Upgrade+Connection "upgrade"— These three headers are required for WebSocket support. Without them, LiveView connections will fail.X-Real-IPandX-Forwarded-For— Pass the client's real IP address to Phoenix (otherwise Phoenix sees all requests coming from 127.0.0.1).X-Forwarded-Proto— Tells Phoenix whether the original request was HTTP or HTTPS. Important for generating correct URLs and enforcing HTTPS redirects.proxy_read_timeout 86400— 24-hour timeout for WebSocket connections. The default (60s) would kill long-lived LiveView connections.
8.2 Enable the Site
ln -sf /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
rm -f /etc/nginx/sites-enabled/default # Remove the default "Welcome to Nginx" page
nginx -t # Test configuration syntax
systemctl reload nginx # Apply without downtime
8.3 Open the Firewall
ufw allow 22/tcp # SSH (don't lock yourself out!)
ufw allow 80/tcp # HTTP
ufw allow 443/tcp # HTTPS
ufw --force enable # Enable firewall
Warning: Always allow SSH (port 22) before enabling the firewall. If you forget, you'll lose access to your server.
At this point, visiting http://yourdomain.com should show your Phoenix app.
Step 9: SSL with Let's Encrypt
Let's Encrypt provides free SSL certificates. Certbot automates the process.
9.1 Install Certbot
apt install -y certbot python3-certbot-nginx
9.2 Obtain a Certificate
certbot --nginx -d yourdomain.com
Certbot will:
- Verify you own the domain (via an HTTP challenge)
- Download and install the SSL certificate
- Automatically modify your Nginx config to handle HTTPS
- Set up HTTP-to-HTTPS redirect
Follow the prompts — enter your email, agree to terms, choose to redirect HTTP to HTTPS.
9.3 Verify Auto-Renewal
Let's Encrypt certificates expire every 90 days. Certbot sets up automatic renewal:
# Check the renewal timer is active
systemctl status certbot.timer
# Test renewal (dry run — doesn't actually renew)
certbot renew --dry-run
9.4 Update Your App's Configuration
After SSL is set up, update your .env file:
nano /home/deploy/.env
# Change PHX_SCHEME to https
# PHX_SCHEME=https
Restart the app:
systemctl restart myapp
Step 10: Automated Deploy Script
Create a script that automates the entire build-deploy process:
nano /home/deploy/deploy.sh
Contents:
#!/bin/bash
set -e # Exit immediately on any error
echo "=== Deploying Application ==="
cd /home/deploy/myapp
source /home/deploy/.env
echo ">>> Installing dependencies..."
MIX_ENV=prod mix deps.get --only prod
echo ">>> Building assets..."
cd assets && npm install && cd ..
MIX_ENV=prod mix assets.deploy
echo ">>> Clean compile..."
rm -rf _build/prod
MIX_ENV=prod mix compile --force
echo ">>> Running migrations..."
MIX_ENV=prod mix ecto.migrate
echo ">>> Building release..."
MIX_ENV=prod mix release --overwrite
echo ">>> Deploying..."
rm -rf /home/deploy/app/myapp
cp -r _build/prod/rel/myapp /home/deploy/app/
chown -R deploy:deploy /home/deploy/app/
echo ">>> Restarting service..."
sudo systemctl restart myapp
sleep 3
echo ">>> Status:"
sudo systemctl status myapp --no-pager | head -5
echo "=== Deploy complete ==="
chmod +x /home/deploy/deploy.sh
chown deploy:deploy /home/deploy/deploy.sh
Future deploys become a two-step process:
- Upload new source to
/home/deploy/myapp - Run
bash /home/deploy/deploy.sh
Maintenance
Viewing Logs
# Application logs (live tail)
journalctl -u myapp -f
# Last 100 lines
journalctl -u myapp -n 100 --no-pager
# Logs from today only
journalctl -u myapp --since today
# PostgreSQL logs
tail -f /var/log/postgresql/postgresql-17-main.log
# Nginx access/error logs
tail -f /var/log/nginx/access.log
tail -f /var/log/nginx/error.log
Database Backups
Set up daily automated backups:
mkdir -p /home/deploy/backups
cat > /home/deploy/backup.sh << 'EOF'
#!/bin/bash
BACKUP_DIR=/home/deploy/backups
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
pg_dump -U postgres myapp_prod | gzip > $BACKUP_DIR/db_$TIMESTAMP.sql.gz
find $BACKUP_DIR -name "db_*.sql.gz" -mtime +7 -delete
echo "Backup completed: db_$TIMESTAMP.sql.gz"
EOF
chmod +x /home/deploy/backup.sh
# Schedule daily at 3 AM
(crontab -l 2>/dev/null; echo "0 3 * * * /home/deploy/backup.sh") | crontab -
Restore from backup:
gunzip < /home/deploy/backups/db_20250101_030000.sql.gz | sudo -u postgres psql myapp_prod
Connecting to the Database
# As postgres superuser
sudo -u postgres psql myapp_prod
# As your app user
psql -U myapp_user -h localhost myapp_prod
Restarting Services
systemctl restart myapp # Restart your app
systemctl restart postgresql # Restart database
systemctl reload nginx # Reload Nginx config (no downtime)
systemctl restart nginx # Full Nginx restart
Troubleshooting
App Won't Start
# Step 1: Check the logs
journalctl -u myapp -n 50 --no-pager
# Step 2: Check if the binary exists
ls -la /home/deploy/app/myapp/bin/myapp
# Step 3: Check permissions
ls -la /home/deploy/app/myapp/
# Step 4: Test manually
source /home/deploy/.env
/home/deploy/app/myapp/bin/myapp start_iex
Database Connection Refused
# Is PostgreSQL running?
systemctl status postgresql
# Can you connect manually?
sudo -u postgres psql -c "SELECT 1"
# Check your DATABASE_URL format
grep DATABASE_URL /home/deploy/.env
# Must be: ecto://USER:PASSWORD@localhost/DATABASE_NAME
"secret_key_base is missing"
# Check your .env file exists and has the right format
cat /home/deploy/.env | grep SECRET_KEY_BASE
# Must be: SECRET_KEY_BASE=abc123... (no quotes, no export)
# Rebuild the release after fixing .env
source /home/deploy/.env
rm -rf _build/prod
MIX_ENV=prod mix compile --force
MIX_ENV=prod mix release --overwrite
OAuth Credentials Not Working
A common gotcha: OAuth credentials (like GOOGLE_CLIENT_ID) must be configured in runtime.exs, not config.exs. Settings in config.exs are evaluated at compile time. In a release, compile time is when you run mix release — your environment variables may not be set then.
# config/runtime.exs (CORRECT — evaluated at app start)
config :ueberauth, Ueberauth.Strategy.Google.OAuth,
client_id: System.get_env("GOOGLE_CLIENT_ID"),
client_secret: System.get_env("GOOGLE_CLIENT_SECRET")
# config/config.exs (WRONG — evaluated at compile time)
# Don't put runtime secrets here!
Nginx Returns 502 Bad Gateway
This means Nginx can't reach Phoenix on port 4000:
# Is the app running?
systemctl status myapp
# Is port 4000 open?
ss -tlnp | grep 4000
# Check Nginx error log
tail -20 /var/log/nginx/error.log
LiveView WebSocket Disconnects
If LiveView connections drop after 60 seconds, the Nginx proxy timeout is too low:
# Add to your location block:
proxy_read_timeout 86400; # 24 hours
SSL Certificate Issues
# Check certificate expiry
openssl s_client -connect yourdomain.com:443 -servername yourdomain.com < /dev/null 2>/dev/null | openssl x509 -dates
# Test renewal
certbot renew --dry-run
# Force renewal
certbot renew --force-renewal
# Check certbot timer
systemctl status certbot.timer
Performance Tips
1. Enable Gzip in Nginx
Add to your server block:
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml;
gzip_min_length 256;
2. Serve Static Assets from Nginx
Phoenix can serve static files, but Nginx is faster:
location /assets/ {
alias /home/deploy/app/myapp/lib/myapp-0.1.0/priv/static/assets/;
expires 1y;
access_log off;
add_header Cache-Control "public, immutable";
}
3. Tune Ecto Pool Size
In your runtime.exs:
config :myapp, MyApp.Repo,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "20")
A pool size of 20 handles most workloads. Increase if you see connection timeout errors.
4. Monitor with LiveDashboard
Phoenix LiveDashboard is built in. Lock it behind authentication in production:
# In your router
live_dashboard "/admin/live-dashboard",
metrics: MyAppWeb.Telemetry,
on_mount: [{MyAppWeb.UserAuth, :ensure_admin}]
Summary
Deploying Phoenix on a VPS is straightforward once you understand the pieces:
- PostgreSQL stores your data
- Elixir release runs your compiled application as a standalone binary
- systemd manages the process lifecycle (start, stop, restart, logs)
- Nginx terminates SSL and proxies requests
- Let's Encrypt provides free SSL certificates
This setup is reliable, performant, and costs $5-20/month. It handles hundreds of concurrent users comfortably, and with Phoenix's efficient WebSocket handling, you can serve thousands of LiveView connections from a single server.
The most important things to remember:
- Always
rm -rf _build/prodbefore building a release - Always
source .envbefore building - Put runtime secrets in
runtime.exs, notconfig.exs - Run as a non-root
deployuser - Set
proxy_read_timeouthigh enough for WebSocket connections