VPS deploy

Simple VPS deployment for Phoenix applications via rsync + SSH.

Syncs your source code to a server, builds a release remotely, and restarts the systemd service — all with mix deploy.

See CHANGELOG.md for version history.

Installation

Add vps_deploy to your list of dependencies in mix.exs:

def deps do
[
{:vps_deploy, "~> 0.1.8", only: :dev}
]
end

Then fetch:

mix deps.get

Setup

Run the interactive setup to generate your configuration:

mix deploy.init

This will ask you for:

The configuration is written to your config/config.exs.

Existing apps that already deploy with 0.1.7 need no config changes. New keys below are optional.

Commands

CommandWhat it does
mix deploy.initWrite config :vps_deploy interactively
mix deploy.setupPasswordless sudo for systemctl (optional systemd unit)
mix deploy.checkPreflight SSH/sudo/Elixir/.env (no rsync, no build)
mix deploy --dry-runPrint rsync dest, excludes, and the remote script
mix deployrsync + remote build + restart
mix deploy.statussystemctl status
mix deploy.logslast 100 journal lines
mix deploy.restartsystemctl restart and wait until active

Manual Configuration

You can also add the configuration manually:

# config/config.exs
config :vps_deploy,
app_name: "my_app",
service_name: "my-app",
vps_user: "deploy",
vps_host: "1.2.3.4",
deploy_dir: "/home/deploy/www/my_app",
use_database: true

Options

OptionRequiredDefaultDescription
app_nameyesApplication name
service_nameno"{app_name}-app"Systemd service name
vps_usernoapp_nameSSH user
vps_hostyesServer IP or hostname
deploy_dirno"/home/{vps_user}/www/{app_name}"Remote deploy path
use_databasenotrueSet to false to skip Ecto migrations during deploy
excludesnosee defaults belowFiles/dirs to exclude from rsync
remote_scriptnoPhoenix build scriptCustom remote build script (never rewritten)
delete_mix_locknotrueSet false to keep mix.lock on the server
assetsnotruefalse skips esbuild/tailwind/assets.deploy; :detect runs them only if those deps exist locally
install_assetsnotrueSet false to skip esbuild.install / tailwind.install (still runs assets.deploy unless assets: false)
migrate_moduleno{App}.ReleaseModule used as eval "Module.migrate"
health_pathnounsetAfter systemd is active, curl this path on 127.0.0.1:$PORT (skipped if PORT is missing)

Environment Variable Overrides

VPS_USER and VPS_HOST override config values at runtime:

VPS_HOST=staging.example.com mix deploy

Default Excludes

.expert _build deps docs .git .hex .mix .env
erl_crash.dump scripts .DS_Store
priv/static/files priv/static/uploads priv/uploads
AGENTS.md
.deploy.lock

Setting excludes still replaces the default list (existing configs keep working). .env and .deploy.lock are always excluded on top of that, so rsync --delete cannot wipe server secrets or a deploy in progress.

Server Setup (first time)

Before your first deploy, configure passwordless sudo for systemctl commands:

mix deploy.setup

This SSHs into your server and creates a sudoers rule so the deploy user can stop/start/restart the application service without a password prompt. You'll be asked for the sudo password once during setup.

It will also ask whether to install a systemd unit (default: no, so existing servers are left alone). Force it with:

mix deploy.setup --unit
mix deploy.setup --unit --enable
mix deploy.setup --ssh-user root

--enable runs systemctl enable only (no start/restart).

First-time server (systemd unit)

If you prefer to write the unit yourself:

# /etc/systemd/system/my-app.service
[Unit]
Description=my_app
After=network.target
[Service]
Type=simple
User=deploy
WorkingDirectory=/home/deploy/www/my_app
EnvironmentFile=/home/deploy/www/my_app/.env
ExecStart=/home/deploy/www/my_app/_build/prod/rel/my_app/bin/my_app start
ExecStop=/home/deploy/www/my_app/_build/prod/rel/my_app/bin/my_app stop
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable my-app

Existing projects

If you have projects already deployed before this update, update the dependency and run setup:

mix deps.update vps_deploy
mix deploy.setup

Or configure sudoers manually on the server:

sudo visudo -f /etc/sudoers.d/YOUR-SERVICE-NAME

Add these lines (replace deploy with your user and my-app with your service name):

deploy ALL=(root) NOPASSWD: /usr/bin/systemctl stop my-app
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl start my-app
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart my-app
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl status my-app
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl status my-app --no-pager
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl is-active my-app
deploy ALL=(root) NOPASSWD: /usr/bin/systemctl is-active --quiet my-app

Deploying

mix deploy

This will:

  1. rsync your source code to the server (excluding configured paths)
  2. SSH into the server and run the build pipeline:
    • rm -f mix.lock
    • mix deps.get --only prod
    • MIX_ENV=prod mix tailwind.install
    • MIX_ENV=prod mix compile
    • MIX_ENV=prod mix assets.deploy
    • MIX_ENV=prod mix release --overwrite
    • Stop the systemd service
    • Run migrations (skipped if use_database: false)
    • Start the systemd service
    • Health check (deploy fails if the service does not become active within about 60 seconds)

Custom Build Script

If your project has a different build pipeline, override remote_script:

config :vps_deploy,
app_name: "my_app",
vps_host: "1.2.3.4",
remote_script: """
set -e
cd /home/deploy/www/my_app
rm -f mix.lock
mix deps.get --only prod
MIX_ENV=prod mix compile
MIX_ENV=prod mix release --overwrite
sudo systemctl restart my-app
"""

Environment Variables

Each app reads its runtime configuration from a .env file at its deploy directory (e.g. /home/deploy/www/my_app/.env). This file is never synced — it lives only on the server (.env is in the default rsync excludes), so secrets stay server-side.

During deploy, the migration step loads .env for the duration of that single command:

set -a; . /home/deploy/www/my_app/.env; set +a

This is scoped — it does not leak into your shell or other apps.

Running multiple apps on one server

Do not source every app's .env from ~/.bashrc. .bashrc runs once per login shell, so all the files merge into one environment and any shared key (PHX_SERVER, PORT, SECRET_KEY_BASE, DATABASE_URL, …) is overwritten by whichever .env was sourced last — commands then run with the wrong app's config.

Instead:

Server Prerequisites

License

Created and maintained by Rafael Egli. Copyright (c) 2026 e9li GmbH, Switzerland. Released under the MIT License (stated 2026-08-23): use it freely; it comes as is, without warranty. Rafael Egli and e9li GmbH are not responsible for problems caused by using this software. Tagged releases keep the license file they shipped with.

Contributing

Please open an issue on the GitHub mirror: https://github.com/e9li/vps_deploy/issues.

Pull requests are not accepted. The GitHub repo is for issues and browsing; the canonical source is https://git.e9li.com/e9li/vps_deploy. See CONTRIBUTING.md.