All Posts

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:

  1. Adds the PostgreSQL APT repository for your Ubuntu version
  2. Downloads and installs the PostgreSQL GPG signing key (so apt trusts packages from this repo)
  3. 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-17 installs the PostgreSQL 17 server
  • postgresql-contrib-17 installs additional modules (like citext for case-insensitive text, pg_trgm for fuzzy matching)
  • systemctl enable configures PostgreSQL to start automatically on server boot
  • systemctl start starts 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 Ecto pool_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 deploy with 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 export prefix — systemd's EnvironmentFile doesn't support it
  • No quotes — SECRET_KEY_BASE=abc123 not SECRET_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-IP and X-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:

  1. Verify you own the domain (via an HTTP challenge)
  2. Download and install the SSL certificate
  3. Automatically modify your Nginx config to handle HTTPS
  4. 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:

  1. Upload new source to /home/deploy/myapp
  2. 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:

  1. PostgreSQL stores your data
  2. Elixir release runs your compiled application as a standalone binary
  3. systemd manages the process lifecycle (start, stop, restart, logs)
  4. Nginx terminates SSL and proxies requests
  5. 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/prod before building a release
  • Always source .env before building
  • Put runtime secrets in runtime.exs, not config.exs
  • Run as a non-root deploy user
  • Set proxy_read_timeout high enough for WebSocket connections