Self-host your Elixir Phoenix backend on a VPS using Dokploy. Complete Docker deployment guide with PostgreSQL and SSL.
Deploying Phoenix to VPS with Dokploy
Self-hosting your Phoenix backend gives you complete control over your infrastructure. Using Dokploy simplifies the process while maintaining flexibility. This guide walks through deploying Phoenix to your own VPS.
Prerequisites
- VPS with 2GB+ RAM (4GB recommended)
- Ubuntu 20.04/22.04 LTS
- Domain name pointing to your server
- Phoenix application ready for production
Step 1: Server Preparation
Connect and Update
ssh root@your-server-ip
apt update && apt upgrade -yCreate Deploy User
adduser deploy
usermod -aG sudo deployConfigure SSH (Optional)
nano /etc/ssh/sshd_config
# Disable root login
PermitRootLogin no
# Use key auth only
PasswordAuthentication no
systemctl restart sshdInstall Docker
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
usermod -aG docker deploy
systemctl enable dockerStep 2: Install Dokploy
curl -sSL https://dokploy.com/install.sh | bashOpen firewall:
ufw allow 3000/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enableAccess Dokploy:
http://your-server-ip:3000Complete setup:
- Create admin account
- Add your server
Step 3: Set Up PostgreSQL
Option A: Dokploy Database Service
- Go to Dokploy Dashboard → Databases
- Click "Create Database"
- Select "PostgreSQL"
- Configure:
- Name:
postgres - Version:
15 - Database:
task_master_prod - User:
task_master
- Name:
- Save password securely
Option B: Docker Compose (More Control)
Create /opt/postgres/docker-compose.yml:
version: '3.8'
services:
postgres:
image: postgres:15-alpine
environment:
POSTGRES_USER: task_master
POSTGRES_PASSWORD: your-secure-password
POSTGRES_DB: task_master_prod
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "127.0.0.1:5432:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U task_master"]
interval: 10s
timeout: 5s
retries: 5
volumes:
postgres_data:Run:
cd /opt/postgres
docker compose up -dStep 4: Prepare Phoenix Application
Release Configuration
Create backend/lib/task_master/release.ex:
defmodule TaskMaster.Release do
@moduledoc """
Release tasks for production
"""
@app :task_master
def migrate do
load_app()
for repo <- repos() do
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true))
end
end
def rollback(repo, version) do
load_app()
{:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version))
end
defp repos do
Application.fetch_env!(@app, :ecto_repos)
end
defp load_app do
Application.load(@app)
end
endCreate backend/rel/overlays/bin/migrate:
#!/bin/sh
cd -P -- "$(dirname -- "$0")"
exec ./task_master eval TaskMaster.Release.migrateMake executable:
chmod +x backend/rel/overlays/bin/migrateCreate backend/rel/overlays/bin/server:
#!/bin/sh
cd -P -- "$(dirname -- "$0")"
PHX_SERVER=true exec ./task_master startMake executable:
chmod +x backend/rel/overlays/bin/serverRuntime Configuration
Update backend/config/runtime.exs:
import Config
# Database configuration
database_url =
System.get_env("DATABASE_URL") ||
raise """
environment variable DATABASE_URL is missing.
For example: ecto://USER:PASS@HOST/DATABASE
"""
config :task_master, TaskMaster.Repo,
url: database_url,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "10"),
ssl: System.get_env("DATABASE_SSL") == "true"
# Secret keys
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise "SECRET_KEY_BASE environment variable is missing"
guardian_secret_key =
System.get_env("GUARDIAN_SECRET_KEY") ||
raise "GUARDIAN_SECRET_KEY environment variable is missing"
# Server configuration
host = System.get_env("PHX_HOST") || "localhost"
port = String.to_integer(System.get_env("PORT") || "4000")
config :task_master, TaskMasterWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
ip: {0, 0, 0, 0},
port: port
],
secret_key_base: secret_key_base,
server: true
config :task_master, TaskMaster.Guardian,
secret_key: guardian_secret_key
# CORS for frontend
config :cors_plug,
origin: [System.get_env("FRONTEND_URL", "http://localhost:5173")],
max_age: 86400,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
# Email configuration
if api_key = System.get_env("RESEND_API_KEY") do
config :task_master, TaskMaster.Mailer,
adapter: Swoosh.Adapters.Resend,
api_key: api_key
endDockerfile
Create backend/Dockerfile:
# Build stage
FROM hexpm/elixir:1.16.0-erlang-26.2.1-debian-bookworm-20231009-slim AS builder
# Install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
WORKDIR /app
# Install hex + rebar
RUN mix local.hex --force && \
mix local.rebar --force
ENV MIX_ENV=prod
# Install dependencies
COPY mix.exs mix.lock ./
RUN mix deps.get --only $MIX_ENV
RUN mkdir config
# Copy config files
COPY config/config.exs config/${MIX_ENV}.exs config/
RUN mix deps.compile
COPY priv priv
COPY lib lib
COPY assets assets
# Build assets
RUN mix assets.deploy
# Compile release
RUN mix compile
COPY config/runtime.exs config/
COPY rel rel
RUN mix release
# Production stage
FROM debian:bookworm-slim AS runner
RUN apt-get update -y && apt-get install -y libstdc++6 openssl libncurses5 locales \
&& apt-get clean && rm -f /var/lib/apt/lists/*_*
# Set locale
RUN sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
ENV LANG=en_US.UTF-8
ENV LANGUAGE=en_US:en
ENV LC_ALL=en_US.UTF-8
ENV MIX_ENV=prod
WORKDIR /app
RUN chown nobody /app
# Copy release
COPY --from=builder --chown=nobody:root /app/_build/${MIX_ENV}/rel/task_master ./
USER nobody
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD /app/bin/task_master rpc "TaskMasterWeb.Endpoint |> Phoenix.Endpoint.__sockets__() |> length()" || exit 1
EXPOSE 4000
CMD ["/app/bin/server"]Create backend/.dockerignore:
.git
.gitignore
README.md
Dockerfile
.dockerignore
/_build/
/deps/
/doc/
/tmp/
.elixir_ls
.vscode/
.idea/
.DS_Store
# Assets
assets/node_modules/
priv/static/assets/
priv/static/cache_manifest.jsonStep 5: Configure Dokploy
Create Application
- Dokploy Dashboard → Applications
- Click "Create Application"
- Configure:
- Name:
backend - Description: Phoenix API backend
- Name:
Source Configuration
- Select Git Provider (GitHub/GitLab)
- Choose repository
- Branch:
main - Build Path:
backend
Build Configuration
- Build Type:
Dockerfile - Dockerfile Path:
Dockerfile - Publish Directory: (leave empty)
Environment Variables
Add in Dokploy UI:
| Variable | Value | Description |
|---|---|---|
DATABASE_URL |
ecto://task_master:password@postgres:5432/task_master_prod |
Database connection |
SECRET_KEY_BASE |
(generate with mix phx.gen.secret) |
Phoenix secret |
GUARDIAN_SECRET_KEY |
(generate with mix phx.gen.secret) |
JWT secret |
PHX_HOST |
tcs-svc-9k2m.yourdomain.com |
Host for URLs (obscure subdomain) |
PORT |
4000 |
Internal port |
FRONTEND_URL |
https://yourdomain.com |
CORS origin |
POOL_SIZE |
10 |
DB connection pool |
RESEND_API_KEY |
(your key) | Email service |
Generate secrets locally:
cd backend
mix phx.gen.secret
# Run twice for both secretsDomain Configuration
- Go to Domains tab
- Add domain:
tcs-svc-9k2m.yourdomain.com - Enable HTTPS
- Port:
4000
Health Check
Configure in Dokploy:
- Path:
/api/health - Interval: 30s
- Timeout: 5s
Create health endpoint in lib/task_master_web/controllers/health_controller.ex:
defmodule TaskMasterWeb.HealthController do
use TaskMasterWeb, :controller
def index(conn, _params) do
json(conn, %{status: "ok", timestamp: DateTime.utc_now()})
end
endAdd to router:
get "/api/health", HealthController, :indexStep 6: Database Migrations
Initial Setup
After first deploy, run migrations:
- In Dokploy, go to your application
- Click "Terminal"
- Run:
/app/bin/migrateAutomated Migrations
Add to Dokploy deploy settings:
- Pre-deploy command:
/app/bin/migrate
This runs migrations before each deployment.
Step 7: Deploy
First Deploy
- Click "Deploy" in Dokploy
- Monitor build logs
- Check container logs after start
Verify Deployment
# Test health endpoint
curl https://tcs-svc-9k2m.yourdomain.com/api/health
# Test API
curl https://tcs-svc-9k2m.yourdomain.com/api/guidebooksTroubleshooting
Check logs in Dokploy:
- Build logs
- Container logs
- System logs
Common issues:
- Database connection failed → Check DATABASE_URL
- Port already in use → Change port mapping
- Out of memory → Increase server RAM or add swap
Step 8: Configure DNS
At your DNS provider:
Type: A
Name: tcs-svc-9k2m
Value: your-server-ip
TTL: 3600Security Note: Use an obscure subdomain like tcs-svc-9k2m instead of predictable names like api or backend. Attackers run automated scans against common subdomain names. An unguessable subdomain hides your backend from reconnaissance tools and significantly reduces automated attack attempts.
Wait for propagation:
dig tcs-svc-9k2m.yourdomain.comStep 9: SSL Certificate
Dokploy with Traefik automatically provisions SSL via Let's Encrypt.
Verify:
curl -I https://tcs-svc-9k2m.yourdomain.comShould show:
HTTP/2 200
strict-transport-security: max-age=31536000Step 10: Continuous Deployment
Webhook Setup
- In Dokploy → Application → Settings → Webhooks
- Copy webhook URL
- Add to GitHub/GitLab repository webhooks
- Content type:
application/json - Events: Push
Auto-Deploy
Enable in Dokploy:
- Auto Deploy: On
- Branch: main
Step 11: Backup Strategy
Database Backups
Create backup script /opt/backup/backup.sh:
#!/bin/bash
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/opt/backups"
DB_NAME="task_master_prod"
DB_USER="task_master"
# Create backup
docker exec postgres pg_dump -U $DB_USER $DB_NAME > "$BACKUP_DIR/backup_$DATE.sql"
# Compress
gzip "$BACKUP_DIR/backup_$DATE.sql"
# Keep only last 7 days
find $BACKUP_DIR -name "backup_*.sql.gz" -mtime +7 -delete
# Upload to S3 (optional)
# aws s3 cp "$BACKUP_DIR/backup_$DATE.sql.gz" s3://your-bucket/backups/Make executable and add to cron:
chmod +x /opt/backup/backup.sh
crontab -e
# Add line:
0 2 * * * /opt/backup/backup.shStep 12: Monitoring
Basic Monitoring
Install on server:
docker run -d \
--name=netdata \
-p 19999:19999 \
-v /proc:/host/proc:ro \
-v /sys:/host/sys:ro \
-v /var/run/docker.sock:/var/run/docker.sock:ro \
--cap-add SYS_PTRACE \
--security-opt apparmor=unconfined \
netdata/netdataAccess at http://your-server:19999
Application Monitoring
Add telemetry to Phoenix and export to:
- Prometheus + Grafana
- Datadog
- New Relic
Production Checklist
- Server secured (SSH, firewall)
- Database created and accessible
- All environment variables set
- Secrets are strong and unique
- Health check endpoint working
- SSL certificate active
- DNS configured and propagated
- Migrations run successfully
- API responds correctly
- Logs showing no errors
- Backup script configured
- Monitoring in place
- Auto-deploy configured
Cost Comparison
| Platform | Monthly Cost | Notes |
|---|---|---|
| Fly.io | $2-20 | Depends on usage |
| Dokploy on Hetzner | €5-10 | CX21 server |
| Dokploy on DigitalOcean | $12-24 | Basic droplet |
Self-hosting is often cheaper for steady traffic, more expensive for spikes.
Next Steps
- SSL and Domain Configuration
- Set up monitoring and alerting
- Configure log aggregation
- Implement automated backups