Página en inglés
Esta página de referencia todavía no está traducida al español. Las guías (Primeros pasos, Flujos, Videos, Ejemplos) sí lo están.
⚙️ Configuration Reference (deploy.yml)¶
Every option of the versaDeploy configuration file, with defaults and behavior. For a commented example see deploy.example.yml, and per framework (Laravel on PHP-FPM or FrankenPHP, Next.js, Nuxt, Flask, FastAPI, Gin) examples/; to generate a starter file run versa init. Check a file anytime with versa config validate [env].
- File structure
- Environment variables in the config
- SSH connection (
ssh) - Environment settings
- Builds (
builds): PHP · Go · Frontend · Python - Files between releases: shared, preserved, reusable ·
.envand secrets - Hooks
- Service reload (
services_reload) - Services (
services): run a Go/Python process at boot and restart it on deploy - Health check (
health_check) - Notifications (
notifications) - Deploy history · Production from tags (
deploy_tags) - Performance and limits
- Local mode (no SSH)
- Server directory layout
- Deprecated options
File structure¶
project: "my-app" # required
environments:
production: # the name you pass to `versa deploy production`
ssh: { ... }
remote_path: "/var/www/my-app"
builds: { ... } # at least one build must be enabled
shared_paths: [ ... ]
post_deploy: [ ... ]
# ...
staging:
# ...
| Field | Type | Description |
|---|---|---|
project |
string | Required. Project name, used in logs and notifications. |
environments |
map | Required. At least one environment. Each key is an environment name. |
Every setting below lives inside an environment and must be indented one level deeper than the environment name. Settings are not inherited between environments.
You can have several config files in one repo (e.g. one per server: deploy_server1.yml, deploy_server2.yml); see Choosing the configuration file.
Environment variables in the config¶
Before parsing, ${NAME} and $NAME are replaced with the value of the environment variable on your machine (empty if undefined). Use it to keep secrets out of the file:
notifications:
webhook_url: "${SLACK_WEBHOOK}"
This applies to the whole file, hooks included. To pass a variable to the server's shell instead, write $$, which becomes a literal $:
post_deploy:
- "echo $$HOME" # the server runs: echo $HOME
- "cp .env.example $${APP_DIR}" # the server runs: cp .env.example ${APP_DIR}
- "awk '{print $1}' file" # unchanged: $1, $@, $?, $# … are never replaced
A $name that isn't set on your machine prints a warning when the file is loaded ($x is not set here, so it becomes empty): it's almost always a server-side variable that needs $$, e.g. php -r '$$c = 1;'.
Only names that start with a letter or _ are replaced, so positional and special shell variables ($1, $@, $?, $#) pass through untouched.
SSH connection (ssh)¶
Required unless the environment uses local mode.
| Field | Type | Default | Description |
|---|---|---|---|
host |
string | — | Required. Server hostname or IP. |
user |
string | — | Required. SSH user. Needs write access to remote_path. |
key_path |
string | — | Required. Private key. ~/ is expanded. On Linux/macOS the key must be chmod 600. |
port |
int | 22 |
SSH port. |
use_ssh_agent |
bool | false |
Also try the keys loaded in your SSH agent. |
known_hosts_file |
string | ~/.ssh/known_hosts |
File used to verify the server's host key. ~/ is expanded. |
strict_host_key |
bool | false |
Refuse to connect if the host key can't be verified (missing/unreadable known_hosts). See below. |
legacy_algorithms |
bool | false |
Enable SHA1 key exchanges, CBC ciphers and ssh-dss host keys for very old servers (OpenSSH < 7, e.g. CentOS 5). Insecure: only when needed. |
Paths can be Unix-style (~/.ssh/id_rsa) or Windows-style (C:\Users\me\.ssh\id_rsa).
Host key verification¶
versa checks the server's identity against known_hosts, like ssh does:
| Situation | Result |
|---|---|
Server is in known_hosts and the key matches |
Connects. |
Server is in known_hosts with a different key |
Refuses (key mismatch): possible man-in-the-middle, or the server was reinstalled. |
known_hosts missing/unreadable |
Connects with a Host key NOT verified warning, or refuses if strict_host_key: true. |
The easiest way to add a server is to connect once with the regular ssh client and accept the key, or:
ssh-keyscan -p 22 your-server.com >> ~/.ssh/known_hosts
Environment settings¶
| Field | Type | Default | Description |
|---|---|---|---|
remote_path |
string | — | Required (SSH mode). Absolute path on the server; releases, current, shared/ and deploy.lock live here. |
ignored_paths |
list | .git, tests, node_modules/.cache, vendor/bin |
Repo-relative paths excluded from change detection and removed from the release after building (builds still see them). Setting this list replaces the defaults. |
shared_paths |
list | [] |
Paths kept in <remote_path>/shared and symlinked into every release. See below. |
preserved_paths |
list | [] |
Paths copied from the previous release over the new one (the server's copy wins). |
pre_deploy_local |
list | [] |
Hooks run on your machine before building. |
pre_deploy_server |
list | [] |
Hooks run on the server before activating the release. |
post_deploy |
list | [] |
Hooks run on the server after activating the release. |
on_failure |
list | [] |
Hooks run on the server after a failed deploy (e.g. php artisan up). |
deploy_tags |
string | — | Only deploy commits with a git tag matching this glob, e.g. "v*". See below. |
services_reload |
list | [] |
Commands run on the server right after activation, see below. |
health_check |
map | — | See Health check. |
notifications |
map | — | See Notifications. |
route_files |
list | [] |
Files whose change sets route_cache_regenerate: true in the release's manifest.json. |
hook_timeout |
int (s) | 300 |
Limit for each remote hook, and for versa exec. |
deploy_timeout |
int (s) | 600 |
Limit for the whole deploy, checked between phases. |
releases_to_keep |
int | 5 |
Releases kept on the server; older ones are deleted after each deploy. Also the limit of how far back you can roll back. |
upload_workers |
int | 4 |
Parallel SFTP uploads. |
chunk_size_mb |
int | 10 |
Size of each uploaded archive chunk. |
incremental_upload |
bool | false |
Upload only changed files; see Performance. |
services |
list | [] |
Processes run by the server's init system, see Services. |
local, local_path |
bool, string | false, — |
Local mode. |
Builds (builds)¶
At least one build must be enabled. All enabled builds run concurrently on your machine, inside a copy of the repository (HEAD), so the needed tools (composer, go, npm/pnpm, python3 …) must be installed locally. Builders only run when relevant files changed (or with --force).
Paths in root are relative to the repository root.
PHP (php)¶
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable the PHP builder. |
root |
string | "" |
Directory containing composer.json. |
composer_command |
string | composer install --no-dev --optimize-autoloader --classmap-authoritative |
Install command. |
reusable_paths |
list | vendor |
Reused from the previous release when composer.json/composer.lock didn't change. vendor is always included. |
Dependencies are reinstalled when composer.json or composer.lock changes.
Go (go)¶
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable the Go builder. |
root |
string | "" |
Directory containing go.mod. |
target_os |
string | — | Required. e.g. linux. |
target_arch |
string | — | Required. e.g. amd64, arm64, 386. |
binary_name |
string | — | Required. Output file name. |
deploy_path |
string | bin |
Directory relative to the release root (not to app/) for the binary: the binary ends up at <remote_path>/current/<deploy_path>/<binary_name>. Use different paths for several services. |
build_flags |
string | "" |
Extra go build flags. |
cgo |
bool | false |
Build with CGO_ENABLED=1. By default binaries are static (CGO_ENABLED=0). |
To run the binary as a service that starts at boot and is restarted on each deploy, add it to services.
Before building, versa compares the target with the server (uname) and with the minimum kernel your Go version supports, and stops early if the binary couldn't run there. Go is rebuilt only when .go files, go.mod or go.sum change.
Frontend (frontend)¶
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable the frontend builder. |
root |
string | "" |
Directory containing package.json. |
npm_command |
string | npm ci --only=production |
Install command. Runs when package.json or its lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock) changed, or when node_modules is missing. Usually you want dev deps for building: npm ci, pnpm install. |
compile_command |
string | — | Required. Build command, e.g. npm run build. If it contains {file}, it runs once per changed frontend file with {file} replaced. |
cleanup_dev_deps |
bool | false |
After building, delete node_modules and run production_command. |
production_command |
string | pnpm install --production |
Production-only install used by cleanup_dev_deps. |
reusable_paths |
list | node_modules |
Reused from the previous release when package.json and its lockfile didn't change. node_modules is always included. |
server_install |
string | — | Install node_modules on the server with this command (e.g. npm ci --omit=dev) instead of shipping yours: native modules (sharp, bcrypt, better-sqlite3…) built on Windows or macOS don't run on Linux. Runs before the switch (a failure stops the deploy); reused when the lockfile didn't change. Ship the lockfile (don't ignore it) and install Node on the server (Services → Node.js). Replaces cleanup_dev_deps. |
SSR apps (Next.js, Nuxt, SvelteKit, Remix, Astro…)¶
They build on your machine like any frontend and run on the server as a services entry behind the web server (provision.proxy_port). nginx rate-limits the app's pages, never its static chunks.
builds:
frontend:
enabled: true
npm_command: "npm ci"
compile_command: "npm run build"
server_install: "npm ci --omit=dev" # Next: node_modules for the server's OS
services:
- name: myshop
exec: "npm start" # next start / node .output/server/index.mjs (Nuxt)
environment: { PORT: "3000", NODE_ENV: production }
provision:
proxy_port: 3000
node_major: "22"
- Nuxt / Nitro, SvelteKit (adapter-node), Astro (node adapter) bundle their dependencies into the build output: skip
server_installand leavenode_modulesout, e.g.compile_command: "npm run build && rm -rf node_modules",exec: "node .output/server/index.mjs". - Next.js needs
node_modules(oroutput: "standalone"withexec: "node .next/standalone/server.js", plus copyingpublic/and.next/staticinto it). - Build-time variables (
NEXT_PUBLIC_*,NUXT_PUBLIC_*) come from your machine; runtime ones fromenvironmentorenv_file. - Each deploy restarts the service: a few seconds of downtime, like Go and Python services.
Tested end to end with Next.js 16 and Nuxt 4 on Ubuntu 24.04 and AlmaLinux 9.
Python (python)¶
Python dependencies are installed on the server, in a virtualenv inside each release (<release>/app/<root>/<venv_path>). A virtualenv built on your machine wouldn't work there (other OS, paths and compiled packages). When the requirements didn't change, the previous release's virtualenv is reused (hardlinked) instead of reinstalling.
The server therefore needs Python 3 with the venv module (Debian/Ubuntu: apt install python3-venv), plus poetry or pipenv if you use them. Your machine doesn't need Python, except for build_binary.
| Field | Type | Default | Description |
|---|---|---|---|
enabled |
bool | false |
Enable the Python builder. |
root |
string | "" |
Directory of the Python project. |
python_command |
string | python3 |
Python on the server used to create the virtualenv. |
package_manager |
string | pip |
pip, poetry or pipenv (the last two must be installed on the server). |
requirements_file |
string | requirements.txt |
Dependencies are reinstalled when this file, pyproject.toml, poetry.lock, Pipfile or Pipfile.lock changes. |
extra_requirements |
list | [] |
Additional requirements files (pip). |
venv_path |
string | .venv |
Virtualenv path, relative to root. |
reusable_paths |
list | [venv_path] |
Reused from the previous release when requirements didn't change. |
install_dev_deps |
bool | false |
Also install dev dependencies (poetry/pipenv). |
use_cache |
bool | false |
Let pip use its cache (--no-cache-dir otherwise). |
pypi_mirror |
string | "" |
Custom package index URL. |
torch_index |
string | "" |
PyTorch index, e.g. https://download.pytorch.org/whl/cpu. |
web_server |
bool | false |
Generate run_server.sh (see below). |
web_framework |
string | — | fastapi/uvicorn, gunicorn, django, flask. |
entry_point |
string | — | Module file of the app (e.g. main.py, api/main.py). Required with build_binary. |
web_host / web_port |
string / int | 0.0.0.0 / 8000 |
Bind address. |
web_workers |
int | — | Worker processes (uvicorn, gunicorn; Django defaults to 2, gunicorn to 4). |
web_threads |
int | — | Threads per worker (gunicorn). |
run_command |
string | — | Replaces the generated start command. |
build_binary |
bool | false |
Build a standalone executable with PyInstaller (requires entry_point and binary_name). Only when running versa on Linux: PyInstaller can't cross-compile. |
binary_name |
string | — | PyInstaller output name. |
extra_pyinstaller_args |
string | "" |
Extra PyInstaller flags. |
service_name |
string | — | Deprecated: becomes services: [{name: ...}] automatically. Use services. |
stop_command, websocket, ws_protocol, ws_channel_layer, source_path, deploy_path |
Accepted but not used. |
run_server.sh¶
With web_server: true, versa writes run_server.sh in the Python root of every release. It changes to its own directory, uses the release's virtualenv and execs the server, so the init system tracks the right process:
web_framework |
Command |
|---|---|
fastapi / uvicorn |
.venv/bin/python -m uvicorn <entry>:app --host H --port P [--workers N] |
gunicorn |
.venv/bin/python -m gunicorn <entry>:app -w N -b H:P [--threads T] |
django |
.venv/bin/python -m gunicorn <project>.wsgi:application -w N -b H:P (finds <project>/wsgi.py; falls back to manage.py runserver with a warning) |
flask |
.venv/bin/python -m flask run --host H --port P (development server: use run_command with gunicorn in production) |
none, with entry_point |
.venv/bin/python <entry_point> |
<entry> is entry_point as a module (api/main.py → api.main). gunicorn/uvicorn must be in your requirements. Migrations and collectstatic aren't run by the script: put them in post_deploy hooks, e.g. ".venv/bin/python manage.py migrate --noinput".
To run it as a service that starts at boot, add it to services.
Files between releases: shared, preserved, reusable¶
Each deploy creates a fresh release directory. Three mechanisms carry things over from one release to the next:
| Option | Where the data lives | On each deploy | Typical use |
|---|---|---|---|
shared_paths |
<remote_path>/shared/<path> |
The path in the release is replaced by a symlink to shared/<path>. Whatever the repo had there is discarded. |
.env, storage, public/uploads, logs |
preserved_paths |
Inside each release | Copied from the previous release over the new one. The first deploy uses the repo's version. | Config files edited on the server |
builds.*.reusable_paths |
Inside each release | Hardlinked (cp -al) from the previous release when dependencies didn't change, instead of reinstalling. |
vendor, node_modules, .venv |
All paths are relative to the app directory (the repository root).
A shared path that doesn't exist yet in shared/ is created once, on the deploy that first needs it:
| The release has… | shared/<path> becomes |
|---|---|
the path itself (Laravel's storage/, a default config.json) |
a copy of it |
<path>.example (.env.example) |
a copy of the example, mode 0640 |
neither, and the name starts with .env |
an empty file, mode 0640 |
| neither | an empty, group-writable directory |
The deploy warns about each path it created. After that, shared/ is never overwritten by a deploy.
Environment variables and secrets (.env, config.json)¶
Your local .env is never uploaded: releases are built from a clone of the repository, where .env is ignored. The server keeps its own.
Where it lives. A shared_paths entry is a path relative to the repository root, the same place the framework looks for it on your machine. With - ".env":
/var/www/myapp/
├── shared/.env ← the real file (one per server, survives deploys)
└── current → releases/20261108_1200/
└── app/.env → /var/www/myapp/shared/.env (symlink made by each deploy)
- PHP apps (Laravel, versaWYS) read
app/.envas on your machine; the symlink leads them toshared/.env. - Services (
env_file: ".env", relative toshared/): the init system loadsshared/.envinto the process environment before starting it, soos.Getenv,process.envandos.environsee the variables without reading any file (Nuxt's.outputand Go binaries rely on this;next startalso readsapp/.envitself). - Monorepo with the app in a subdirectory:
shared_paths: ["backend/.env"]andenv_file: "backend/.env".
To set it up and change it:
- Put the file in
shared_paths(- ".env"). The first deploy createsshared/.envfrom.env.example. - Edit it from the TUI: Shared view → the file →
Enter→e→Ctrl+Ssaves it on the server (the file keeps its mode). Files kept withpreserved_paths(such as versaWYS'sconfig.json) are listed there too. You can also edit any file from the Files view (Enter,e), or upload one withu. - Apply it: services read it when they start (
versa service <env> restart); PHP frameworks that cache their config need their cache command again (php artisan optimize): Operations → h re-runs thepost_deployhooks.
| Where a variable comes from | Use |
|---|---|
shared/.env on the server |
Secrets and per-server settings. PHP apps read it; services load it with env_file. |
services[].environment |
Non-secret settings of a service (PORT, NODE_ENV). |
| Your machine, at build time | Values compiled into the frontend (NEXT_PUBLIC_*, VITE_*): the build runs locally. |
${VAR} in deploy.yml |
Values for versa itself (provision.db_password), expanded locally. Don't put them in remote hooks (see Hooks). |
Ready-made configs per framework are in examples/.
Hooks¶
Commands run at fixed points of the deploy. Each entry is a command string, or a parallel: block whose commands run at the same time:
pre_deploy_local:
- "npm test"
pre_deploy_server:
- "php artisan down || true"
post_deploy:
- "php artisan migrate --force"
- parallel:
- "php artisan config:cache"
- "php artisan route:cache"
- "php artisan up"
| Hook | Runs on | Working directory | When | If it fails |
|---|---|---|---|---|
pre_deploy_local |
Your machine | Repository root | Before checking/cloning the repo. Skipped on --dry-run. |
Deploy aborts. Nothing was touched on the server. |
pre_deploy_server |
Server | <new release>/app |
After upload, before switching current |
Warning only; the deploy continues. |
post_deploy |
Server | <new release>/app |
After switching current and services_reload |
current is switched back to the previous release and the deploy fails (on the first deploy there is nothing to go back to). |
on_failure |
Server | current/app (after the rollback: the release that is live) |
When a deploy fails after connecting to the server | Warning only. |
- Remote hooks run in the SSH user's login shell, with a limit of
hook_timeoutseconds each. - Local hooks (
pre_deploy_local, andpost_deployin local mode) run withsh -c. On Windows,shmust be inPATH(it comes with Git for Windows: addC:\Program Files\Git\bin). - On the first deploy (
--initial-deploy), versa asks before runningpost_deploy, so you can set up.envfirst (how). on_failureundoes what an earlier hook left half done, e.g. maintenance mode:
pre_deploy_server:
- "php artisan down --retry=15" # storage/ is shared: affects the live site
post_deploy:
- "php artisan migrate --force"
- "php artisan up"
on_failure:
- "php artisan up" # never leave the site down after a failed deploy
post_deploy without deploying: versa hooks <env> [indices...].
- $NAME is expanded locally; write $$NAME for a variable of the server's shell.
- Don't put ${SECRET} in remote hooks: the expanded command line is visible to other users of the server (ps). Read secrets on the server instead, e.g. $$DB_PASS from the app's .env in shared/.
Service reload (services_reload)¶
Commands run on the server right after current is switched (before post_deploy), and after every rollback (versa rollback, the TUI, and the automatic rollbacks after a failed post_deploy hook or health check). Each has a 30 s timeout; failures are warnings and never cause a rollback.
services_reload:
- "sudo systemctl reload php8.2-fpm"
- "sudo systemctl reload nginx"
[!IMPORTANT] PHP-FPM must be reloaded after every deploy. OPcache and PHP's
realpath_cacheremember wherecurrentpointed, so without a reload PHP keeps serving the old release for minutes. The SSH user needs passwordlesssudofor these commands; see Getting Started.
Run them on demand with versa services-reload <env>.
Services (services)¶
Long-running processes, such as a Go API or a Python web server, that must keep running, start when the server boots, and pick up each new release. For every service versa:
- Writes its files to
<remote_path>/.versa/: a wrapper script (<name>.sh) plus a systemd unit, an OpenRC script and a SysV init script. All of them point at<remote_path>/current, so they stay valid across deploys and rollbacks. - Detects the server's init system and installs the matching file:
/etc/systemd/system/<name>.service(systemd), or/etc/init.d/<name>(OpenRC, or SysV init on RHEL/CentOS 5-6 and other servers without systemd). It only copies it when it changed. - Enables it at boot (
systemctl enable,rc-update add,chkconfig --addorupdate-rc.d). - Restarts it after the
post_deployhooks, waitsstart_waitseconds and checks that it's still running. If it isn't, the deploy is rolled back. - Restarts it again after every rollback (
versa rollback, the TUI, and automatic rollbacks).
services:
- name: "api" # Go binary: exec defaults to it
env_file: ".env" # <remote_path>/shared/.env
environment:
PORT: "8080"
- name: "web" # Python with web_server: exec defaults to run_server.sh
| Field | Type | Default | Description |
|---|---|---|---|
name |
string | — | Required. Name in the init system (systemctl status <name>). Letters, digits, -, _, ., @. |
exec |
string | see below | Command line. A leading ./ means relative to <remote_path>/current: ./bin/go/api --port 8080. |
working_dir |
string | app (app/<python.root> for Python-only projects) |
Directory, relative to <remote_path>/current, the process starts in. |
user |
string | ssh.user |
Unix user the process runs as. |
env_file |
string | — | File under <remote_path>/shared/ with KEY=value lines, loaded into the process environment. |
environment |
map | — | Extra environment variables, written to the wrapper in .versa/ (mode 0600 when the service runs as ssh.user, 0644 otherwise: put secrets in env_file then). |
start_wait |
int (s) | 3 |
Seconds to wait after (re)starting before checking the process is still running. |
stop_timeout |
int (s) | 30 |
Seconds a stop waits for the process to exit after SIGTERM before killing it (SIGKILL). Raise it if your app needs longer to shut down cleanly. |
reload |
string | — | Command run (as the SSH user) instead of a restart on deploys and rollbacks while the service is up: a graceful reload that drops no requests (e.g. kill -HUP for gunicorn; FrankenPHP: its opcache_reset() socket, proposed by the Services view). A stopped service, or a failed reload, is restarted. versa service <env> restart always restarts. |
Default exec (when omitted):
| Builds enabled | Runs |
|---|---|
go |
<remote_path>/current/<go.deploy_path>/<go.binary_name> |
python + build_binary |
the PyInstaller binary |
python + web_server |
/bin/sh <python root>/run_server.sh |
python + entry_point |
<venv>/bin/python <entry_point> |
go and python, or neither |
none: set exec (one service per process). |
Your program should log to stdout/stderr. Logs go to the journal on systemd (versa service <env> logs) and to /var/log/<name>.log otherwise.
Permissions (root setup and sudo)¶
The init files (systemd unit, OpenRC or SysV script) are installed by root only. .versa/ belongs to the deploy user. If the deploy user could copy a file from there into /etc with sudo, it could write any unit, and units can run as any user: in practice, that is root. Run this once per server, as root:
versa service production setup | ssh root@203.0.113.10 sh
It installs and enables every service, and writes /etc/sudoers.d/versa-<project> (validated with visudo -c). That file only lets the deploy user start / stop / restart / enable / disable the services, daemon-reload, and read their journal with fixed arguments. The Services view does the same in its Sudoers step. versa info <env> checks it.
Run it again when a service's name, user, env_file or stop_timeout changes: those settings live in the installed file. Until you do, deploys stop with "needs root setup". exec, environment and working_dir live in the wrapper in .versa/, so changes to them deploy normally. When ssh.user is root, deploys install the files themselves.
A service can't run as root unless ssh.user is root: the deploy user writes the code it runs. Put the web server in front for ports below 1024.
versa service <env> sudoers still prints just the sudoers lines, with the server's real binary paths:
- It always includes
Defaults:<user> !requiretty: RHEL/CentOS up to 7 shipDefaults requiretty, which makes sudo refuse to run over SSH without a terminal (how versa runs commands). - On servers without
/etc/sudoers.d(RHEL/CentOS 5's sudo 1.7), the lines go at the end of/etc/sudoers(setupprints them for you to add withvisudo). - Commands use this server's absolute paths (e.g.
/sbin/chkconfig), and versa runs them with those same paths: old sudo has nosecure_path, sosudo chkconfigwouldn't match a/sbin/chkconfigrule.
Init systems¶
| Server | Init | Supervision |
|---|---|---|
| Ubuntu 16.04+, Debian 8+, RHEL/CentOS 7+, … | systemd | Restarted automatically if it crashes (Restart=always). |
| Alpine, Gentoo | OpenRC | Started at boot; not restarted if it crashes. |
| RHEL/CentOS 5-6, old Debian, busybox systems | SysV init (/etc/init.d) |
Started at boot (pidfile + nohup); not restarted if it crashes. |
Manage services by hand with versa service.
Server provisioning (provision)¶
Optional block read only by the TUI Services view, which installs and maintains the server stack. Deploys ignore it. It holds the admin (root) connection and the desired stack:
provision:
ssh: { user: root } # inherits host, port and key from ssh
domain: myapp.com
aliases: [www.myapp.com]
web_server: nginx # nginx | apache
php_version: "8.4"
database: postgres # postgres | mariadb | none
db_name: myapp
db_user: myapp_user
db_password: ${DB_PASS} # only used to create a new user; the TUI never writes it
redis: true
ssl: true
backup: true
For Python, Go and Node apps the view proposes the services entry and installs its sudoers rules, so each deploy installs and restarts the service. Every field and the safety rules are in SERVICES.md.
Health check (health_check)¶
Verifies the new release after post_deploy and the service restart. If every attempt fails, current is switched back to the previous release, services are reloaded, and the deploy fails.
health_check:
url: "https://myapp.com/health" # HTTP GET from YOUR machine
expected_status: 200
command: "php artisan about" # and/or a command on the server, in <new release>/app; exit 0 = healthy
timeout: 10
retries: 3
retry_delay: 2
| Field | Default | Description |
|---|---|---|
url |
— | URL requested from the machine running versa. |
expected_status |
200 |
HTTP status considered healthy. |
command |
— | Remote command; healthy if it exits with 0. If both are set, both must pass. |
timeout |
10 |
Seconds per attempt. |
retries |
3 |
Attempts before giving up. |
retry_delay |
2 |
Seconds between attempts. |
Notifications (notifications)¶
Sends a JSON POST when a deploy finishes. Works with Slack and Microsoft Teams incoming webhooks (text field), Discord (content field) and Telegram (chat_id + text). Not sent on --dry-run.
notifications:
webhook_url: "${DEPLOY_WEBHOOK}"
on_success: false # quiet while deploys work…
on_failure: true # …every failure, and "recovered" on the next success
- Failures carry the error and the last 20 lines of the deploy's log (
log_tail, also intext). - Recovered: with
on_failure: true, the first success after a failed deploy is sent even withon_success: false(statusrecovered), so a fix never goes unnoticed. The previous result comes from the server's history. - Telegram: create a bot with @BotFather and use
notifications:
webhook_url: "https://api.telegram.org/bot${TELEGRAM_TOKEN}/sendMessage"
chat_id: "-1001234567890"
on_failure: true
Payload:
{
"text": "[SUCCESS] my-app → production: success (release 20261001-183807, 42s)",
"content": "…same as text…",
"project": "my-app",
"environment": "production",
"release": "20261001-183807",
"commit": "9f2c1e7…",
"status": "success",
"error": "",
"duration_s": 42.1,
"timestamp": "2026-10-01T18:38:49Z",
"tags": ["v1.2.0"],
"log_tail": ""
}
Deploy history¶
Every deploy that reaches the server is recorded there, successful or not, with its full log, and so is every rollback (from versa rollback or the TUI: who went back to which release): <remote_path>/history/history.jsonl (the last 100 deploys) and history/<id>.log (the last 20). Failed deploys are recorded too, although their release is deleted by the rollback. Logs can hold hook output, so the files are 0600.
$ versa history production
ID STATUS TIME COMMIT TAG BY
20261108-153012 success 48s a41f09c3 v1.2.0 ana@laptop
20261108-150140 FAILED 31s c93a11f0 ana@laptop
└ post-deploy hook failed (rolled back to 20261107-101203): …
$ versa history production 20261108-150140 # that deploy's full log
The TUI shows the last 10 under Operations → t, and the logs in Files → history/. BY is user@host of the machine that deployed; set VERSA_DEPLOYER to name it yourself (in CI: VERSA_DEPLOYER: ${{ github.actor }}).
Production from tags (deploy_tags)¶
With deploy_tags, an environment only deploys commits that have a matching git tag, so production changes only when you release a version, while staging deploys any commit:
environments:
staging:
# … every commit
production:
deploy_tags: "v*" # glob: v*, v[0-9]*, release-*
git tag v1.2.0 && git push --tags
versa deploy production # refused if HEAD has no v* tag
The tag shows up in the history and the notifications. From CI, examples/github-actions.yml deploys staging on every push to main and production on every v* tag.
Performance and limits¶
- Change detection: if nothing changed since the server's
deploy.lock, the deploy stops before building. Builders only run for the languages whose files changed. - Upload: the release is compressed into
chunk_size_mbchunks and uploaded byupload_workersparallel workers, then streamed intotaron the server. versa checks free disk space first. incremental_upload: true: the new release starts as a hardlinked copy of the previous one and only changed files are uploaded. Much faster for large projects with small changes.
[!WARNING] With hardlinks, both releases share the same files. Hooks must not modify release files in place (e.g.
echo >> file,sed -i), or the previous release changes too and rollbacks stop being clean. Writing a new file and renaming it is safe.
deploy_timeoutstops a deploy that takes too long overall;hook_timeoutlimits each hook.
Local mode (no SSH)¶
For hosting without SSH (shared hosting with FTP or a file manager). versa builds the project and writes a complete release to local_path, replacing whatever was there. You then upload the contents of local_path/app yourself.
environments:
hosting:
local: true
local_path: "./local-releases/hosting"
builds:
php:
enabled: true
post_deploy:
- "php versaCLI cache:clear" # runs locally, in local_path/app
sshandremote_pathare not needed.- No release history: no rollback,
shared_paths,preserved_pathsorpre_deploy_server(they are ignored with a warning), and noservices. - Every deploy is a full build.
- Don't point
local_pathat a directory your build writes to (e.g. the frontend'sdist). - Only
deployandconfig validatework; see the CLI Reference.
Server directory layout¶
<remote_path>/
├── current -> <remote_path>/releases/20261001-183807 # what your web server serves
├── releases/
│ ├── 20260930-170212/
│ └── 20261001-183807/
│ ├── app/ # your repository (built); hooks run here
│ ├── bin/ # Go binaries (go.deploy_path)
│ └── manifest.json # release metadata
├── shared/ # shared_paths
├── history/ # every deploy: history.jsonl + <id>.log
├── deploy.lock # hashes and metadata of the last deploy
├── .versa/ # generated service files (wrapper, systemd unit, init scripts)
└── .versa.lock/ # exists only while a deploy is running
Point your web server at <remote_path>/current/app/... (e.g. /var/www/app/current/app/public). Release names are UTC timestamps.
Deprecated options¶
| Option | Replacement |
|---|---|
python.service_name |
services: [{name: ...}] (migrated automatically, with a warning). The old .service file in the app directory is no longer written. |
hook_execution_mode |
before_switch → move the hooks to pre_deploy_server (done automatically, with a warning). after_switch → just remove the option. It can't be combined with pre_deploy_local/pre_deploy_server. |