Self-host your Elixir Phoenix backend on a VPS using Dokploy. Complete Docker deployment guide with PostgreSQL and SSL.

20 min read

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 -y

Create Deploy User

adduser deploy
usermod -aG sudo deploy

Configure SSH (Optional)

nano /etc/ssh/sshd_config

# Disable root login
PermitRootLogin no

# Use key auth only
PasswordAuthentication no

systemctl restart sshd

Install Docker

curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
usermod -aG docker deploy
systemctl enable docker

Step 2: Install Dokploy

curl -sSL https://dokploy.com/install.sh | bash

Open firewall:

ufw allow 3000/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

Access Dokploy:

http://your-server-ip:3000

Complete setup:

  1. Create admin account
  2. Add your server

Step 3: Set Up PostgreSQL

Option A: Dokploy Database Service

  1. Go to Dokploy Dashboard → Databases
  2. Click "Create Database"
  3. Select "PostgreSQL"
  4. Configure:
    • Name: postgres
    • Version: 15
    • Database: task_master_prod
    • User: task_master
  5. 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 -d

Step 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
end

Create backend/rel/overlays/bin/migrate:

#!/bin/sh
cd -P -- "$(dirname -- "$0")"
exec ./task_master eval TaskMaster.Release.migrate

Make executable:

chmod +x backend/rel/overlays/bin/migrate

Create backend/rel/overlays/bin/server:

#!/bin/sh
cd -P -- "$(dirname -- "$0")"
PHX_SERVER=true exec ./task_master start

Make executable:

chmod +x backend/rel/overlays/bin/server

Runtime 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
end

Dockerfile

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.json

Step 5: Configure Dokploy

Create Application

  1. Dokploy Dashboard → Applications
  2. Click "Create Application"
  3. Configure:
    • Name: backend
    • Description: Phoenix API backend

Source Configuration

  1. Select Git Provider (GitHub/GitLab)
  2. Choose repository
  3. Branch: main
  4. 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 secrets

Domain Configuration

  1. Go to Domains tab
  2. Add domain: tcs-svc-9k2m.yourdomain.com
  3. Enable HTTPS
  4. 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
end

Add to router:

get "/api/health", HealthController, :index

Step 6: Database Migrations

Initial Setup

After first deploy, run migrations:

  1. In Dokploy, go to your application
  2. Click "Terminal"
  3. Run:
/app/bin/migrate

Automated Migrations

Add to Dokploy deploy settings:

  • Pre-deploy command: /app/bin/migrate

This runs migrations before each deployment.

Step 7: Deploy

First Deploy

  1. Click "Deploy" in Dokploy
  2. Monitor build logs
  3. 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/guidebooks

Troubleshooting

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: 3600

Security 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.com

Step 9: SSL Certificate

Dokploy with Traefik automatically provisions SSL via Let's Encrypt.

Verify:

curl -I https://tcs-svc-9k2m.yourdomain.com

Should show:

HTTP/2 200
strict-transport-security: max-age=31536000

Step 10: Continuous Deployment

Webhook Setup

  1. In Dokploy → Application → Settings → Webhooks
  2. Copy webhook URL
  3. Add to GitHub/GitLab repository webhooks
  4. Content type: application/json
  5. 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.sh

Step 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/netdata

Access 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