Simple VPS deployment for Phoenix applications via rsync + SSH.
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-26 16:41:29 +03:00
lib - dry-run bug fixed 2026-08-26 16:41:29 +03:00
test - dry-run bug fixed 2026-08-26 16:41:29 +03:00
.formatter.exs - prepare for public release 2026-08-23 13:28:37 +03:00
.gitignore - dry-run bug fixed 2026-08-26 16:41:29 +03:00
CHANGELOG.md - dry-run bug fixed 2026-08-26 16:41:29 +03:00
CONTRIBUTING.md - prepare for public release 2026-08-23 13:28:37 +03:00
LICENSE.md - prepare for public release 2026-08-23 13:28:37 +03:00
mix.exs - dry-run bug fixed 2026-08-26 16:41:29 +03:00
README.md - dry-run bug fixed 2026-08-26 16:41:29 +03:00

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.9", 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:

  • App name — your application name (e.g. my_app)
  • Service name — systemd service name (e.g. my-app)
  • VPS user — SSH user on the server
  • VPS host — server IP or domain
  • Deploy directory — where the app lives on the server
  • Use database — whether the app uses Ecto/database (default: yes)
  • Excludes — files/directories to skip during rsync

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

Command What it does
mix deploy.init Write config :vps_deploy interactively
mix deploy.setup Passwordless sudo for systemctl (optional systemd unit)
mix deploy.check Preflight SSH/sudo/Elixir/.env (no rsync, no build)
mix deploy --dry-run Preflight over SSH: plan, exact rsync command, files that would change, remote script preview
mix deploy rsync + remote build + restart
mix deploy.status systemctl status
mix deploy.logs last 100 journal lines
mix deploy.restart systemctl restart and wait until active

--dry-run connects to the server (SSH check + rsync --dry-run) but changes nothing and takes no deploy lock. Only mix deploy accepts it; the other tasks reject the flag.

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

Option Required Default Description
app_name yes — Application name
service_name no "{app_name}-app" Systemd service name
vps_user no app_name SSH user
vps_host yes — Server IP or hostname
deploy_dir no "/home/{vps_user}/www/{app_name}" Remote deploy path
use_database no true Set to false to skip Ecto migrations during deploy
excludes no see defaults below Files/dirs to exclude from rsync
remote_script no Phoenix build script Custom remote build script (never rewritten)
delete_mix_lock no true Set false to keep mix.lock on the server
assets no true false skips esbuild/tailwind/assets.deploy; :detect runs them only if those deps exist locally
install_assets no true Set false to skip esbuild.install / tailwind.install (still runs assets.deploy unless assets: false)
migrate_module no {App}.Release Module used as eval "Module.migrate"
health_path no unset After 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

Preview everything first with mix deploy --dry-run (see Commands).

This will:

  1. rsync your source code to the server (excluding configured paths)
  2. SSH into the server and run the build pipeline:
    • Check passwordless sudo and, when use_database is on, that .env exists
    • rm -f mix.lock
    • mix deps.get --only prod
    • MIX_ENV=prod mix esbuild.install
    • 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:

  • The running services get their env from systemd. Each unit file (/etc/systemd/system/<service>.service) should have, under [Service]:

    EnvironmentFile=/home/deploy/www/my_app/.env
    

    This is per-service and fully isolated. Reload after edits with sudo systemctl daemon-reload && sudo systemctl restart <service>.

  • Interactive commands should load .env only for one command, in a subshell. Add this helper to ~/.bashrc (the function sets nothing until called):

    # usage: with-env /home/deploy/www/my_app mix ecto.migrate
    with-env() {
      local dir="$1"; shift
      ( set -a; . "$dir/.env"; set +a; cd "$dir" && "$@" )
    }
    

    The ( … ) subshell keeps each app's variables from clobbering another's.

Server Prerequisites

  • Elixir and Erlang installed on the server
  • A systemd service configured for your app
  • SSH key-based authentication
  • Passwordless sudo for systemctl commands (run mix deploy.setup)
  • A .env file at the deploy directory with runtime environment variables

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.