deploy is a CLI tool for deploying and managing applications on remote servers over SSH.
It supports multiple deployment types:
odoo— Odoo projects, relying onodoo-venvfor virtual environment creation andodoo-addons-pathfor add-on discovery.python— Generic Python services (FastAPI, Flask, background workers, etc.).service— Any other application type (Node.js, Ruby, compiled binaries, etc.) where the operator controls the build and start commands via configuration.
The tool is distributed as a Python package with a single deploy console script entry point.
Instance names follow the pattern:
<type_prefix>-<project_slug>-<environment>[-<suffix>]
| Segment | Values / Notes |
|---|---|
type_prefix |
Always first. odoo, openerp → Odoo · service → Python |
project_slug |
Everything between prefix and environment. May contain hyphens. |
environment |
integration, staging, production, hotfix, debug, demo |
suffix |
Optional. Short qualifier appended after the environment: -02, -eu, -vn |
Examples
odoo-myproject-production
odoo-myproject-staging-02
odoo-my-cool-project-production
openerp-legacy-integration
service-myapi-production-eu
service-worker-staging
Parsing algorithm — because the slug may contain hyphens, the name is parsed from both ends:
- Prefix — everything before the first
-. - Suffix — if the last segment does not match a known environment, treat it as a suffix and strip it.
- Environment — the last remaining segment, which must be a known value
(
integration,staging,production,hotfix,debug,demo). - Slug — all segments between prefix and environment, joined with
-.
Type auto-detection — when --type is not provided and no type key exists in deploy.yml,
the prefix is used to derive the type:
| Instance name prefix | Detected type |
|---|---|
odoo-, openerp- |
odoo |
service- |
python |
| (anything else) | error — --type is required |
The service deployment type (non-Python) is never auto-detected. It must be set explicitly via
--type service or type: service in deploy.yml.
Database name (Odoo only) — defaults to instance_name verbatim. Override with --db or the
db key in deploy.yml.
These options are accepted by all commands:
| Option | Default | Description |
|---|---|---|
--config |
deploy.yml |
Path to the configuration file (resolved locally) |
--verbose |
False |
Print each remote command and its output as it runs |
When --config is provided (or deploy.yml exists in the current directory), values are read
from the section matching <instance_name>. CLI arguments always take precedence over config
file values, which take precedence over built-in defaults.
Signature
deploy [--config FILE] configure <instance_name> [<ssh_host>] [<repo_url>] [--type odoo|python|service] [-p <ssh_port>] [--force] [--repo-subdir <subdir>]Arguments
| Argument | Required without config | Description |
|---|---|---|
instance_name |
Always | Logical name for the instance (used for paths and service name) |
ssh_host |
If not in config | SSH target, or localhost / omit to deploy locally without SSH |
repo_url |
If not in config | Git repository URL (e.g. git@github.com:org/repo.git) |
Options
| Option | Default | Description |
|---|---|---|
--type |
auto | Deployment type: odoo, python, or service; auto-detected from instance name prefix if omitted |
-p |
22 |
SSH port, default 22 |
--force |
False |
Re-run steps 3–4 even if the instance directory already exists |
--repo-subdir |
None |
Subdirectory within the repository to work on, if any |
Steps (executed in order)
-
Connect — if
ssh_hostis set and is notlocalhost, open an SSH connection. Otherwise all subsequent commands run as local subprocesses. -
Clone repository — clone
repo_urlinto~/<instance_name>on the target host.- If the directory already exists and is a valid Git repository:
- Without
--force: abort with an error and a hint to use--forceor a different name. - With
--force: skip the clone, proceed to steps 3–4.
- Without
- If the directory already exists and is a valid Git repository:
-
Set up the application environment
odoo: create a virtual environment usingodoo-venv:After creation, runodoo-venv --project-dir ~/<instance_name>
odoo-addons-pathto detect and record the add-on directories.python: create a virtual environment and install dependencies usinguv:uv venv .venv if [ -e requirements.txt ]; then uv pip install -r requirements.txt; fi if [ -e pyproject.toml ]; then uv sync; fi
service: run thebuildcommand defined in the localdeploy.yml(e.g.npm ci && npm run build,cargo build --release), executed remotely on the target host. No Python venv is created.
-
Install systemd user unit — render the appropriate bundled template, write the unit file, and register it with the user-level systemd instance (no
sudorequired).- Unit file destination:
~/.config/systemd/user/<instance_name>.service - Template variables per type:
odoo:instance_name,instance_path,venv_path,odoo_addons_pathpython:instance_name,instance_path,venv_path,exec_startservice:instance_name,instance_path,exec_start
- After writing the unit file, run:
loginctl enable-linger systemctl --user daemon-reload systemctl --user enable --now <instance_name>loginctl enable-lingerensures the user's systemd instance (and all--userunits) survive logout. It is idempotent and safe to re-run.
- Unit file destination:
Exit conditions
| Condition | Exit code |
|---|---|
| All steps succeeded | 0 |
| SSH connection failed (remote targets only) | 1 |
Repository already exists (without --force) |
1 |
| Git clone failed | 1 |
| Virtual environment step failed | 1 |
| Template rendering / write failed | 1 |
Signature
deploy [--config FILE] update <instance_name> [<ssh_host>] [-p <ssh_port>] [--type odoo|python|service] [--db DATABASE] [--ignore-hooks] [--repo-subdir <subdir>]Arguments
| Argument | Required without config | Description |
|---|---|---|
instance_name |
Always | Name of the previously configured instance |
ssh_host |
If not in config | SSH target, or localhost / omit to deploy locally without SSH |
Options
| Option | Default | Description |
|---|---|---|
--type |
auto | Deployment type: odoo, python, or service; auto-detected from instance name prefix if omitted |
-p |
22 |
SSH port, default 22 |
--db |
<instance_name> |
(Odoo only) Override the target database name |
--ignore-hooks |
False |
Skip all hook execution |
--repo-subdir |
None |
Subdirectory within the repository to work on, if any |
Hooks
Hooks are shell commands defined in deploy.yml under a hooks key, executed remotely on
ssh_host from the ~/<instance_name> working directory.
# deploy.yml
<instance_name>:
hooks:
pre-update:
- ./scripts/check_disk_space.sh
- ./scripts/notify_slack.sh "Update starting"
pre-update-required:
- ./scripts/run_tests.sh # update is aborted if this fails
pre-update-success:
- ./scripts/notify_slack.sh "Pre-checks passed"
pre-update-fail:
- ./scripts/notify_slack.sh "Pre-checks failed"
post-update:
- ./scripts/smoke_test.sh
post-update-success:
- ./scripts/notify_slack.sh "Update succeeded"
post-update-fail:
- ./scripts/notify_slack.sh "Update failed"This allows a single deploy.yml to carry configuration for multiple instances in the same
repository.
Hook semantics:
| Hook name | When it runs | Blocks update on failure? |
|---|---|---|
pre-update |
Before any update step | No |
pre-update-required |
After pre-update; failure aborts the update |
Yes |
pre-update-success |
After pre-update phase, only if all hooks succeeded | No |
pre-update-fail |
After pre-update phase, only if any hook failed | No |
post-update |
After update completes (success or failure) | No |
post-update-success |
After update, only if it succeeded | No |
post-update-fail |
After update, only if it failed | No |
Steps (executed in order)
-
Connect — if
ssh_hostis set and is notlocalhost, open an SSH connection. Otherwise all subsequent commands run as local subprocesses. -
Run
pre-updatehooks — execute allpre-updatecommands in order. -
Run
pre-update-requiredhooks — execute in order; if any fails, runpre-update-failhooks and abort with exit code 1. -
Run
pre-update-successorpre-update-fail— based on the outcome of steps 2–3. -
Pull latest code — inside
~/<instance_name>, rungit pull. Abort if the directory does not exist or is not a Git repository. -
Update dependencies / rebuild
odoo: the script expects all changes in dependencies, even from odoo, are explicitly listed in requirements.txt, re-runuv pip install -r requirements.txtto sync the virtual environment.python: runuv pip install -r requirements.txt(if the file exists) oruv syncif the file pyproject.toml exists.service: re-run thebuildcommand fromdeploy.yml.
-
Apply changes
odoo: activate the virtual environment and run the upgrade, then restart the unit:~/<instance_name>/.venv/bin/click-odoo-upgrade -d <database_name> systemctl --user restart <instance_name>
python/service: restart the user-level systemd unit:systemctl --user restart <instance_name>
-
Run
post-updatehooks, thenpost-update-successorpost-update-faildepending on whether step 7 succeeded.
Exit conditions
| Condition | Exit code |
|---|---|
| All steps succeeded | 0 |
| SSH connection failed (remote targets only) | 1 |
| Instance directory not found | 1 |
pre-update-required hook failed |
1 |
git pull failed |
1 |
| Dependency update failed | 1 |
| Upgrade / restart command failed | 1 |
Signature
deploy [--config FILE] status <instance_name> [<ssh_host>] [-p <ssh_port>]Arguments
| Argument | Required without config | Description |
|---|---|---|
instance_name |
Always | Name of the previously configured instance |
ssh_host |
If not in config | SSH target, or localhost / omit to deploy locally without SSH |
ssh_port |
If not in config | SSH port, default 22 |
Output
Prints a summary of the instance on the remote host:
Instance: <instance_name>
Remote: git@github.com:org/repo.git
Branch: main (abc1234)
Unit: active (running) since 2026-03-09 08:12:03
Steps (executed in order)
-
Connect — if
ssh_hostis set and is notlocalhost, open an SSH connection. Otherwise all subsequent commands run as local subprocesses. -
Git info — inside
~/<instance_name>, run:git remote get-url origin→ remote URLgit rev-parse --abbrev-ref HEAD→ current branchgit rev-parse --short HEAD→ current commit hash
-
Unit status — run
systemctl --user is-active <instance_name>andsystemctl --user show <instance_name> --property=ActiveState,SubState,ActiveEnterTimestampto retrieve state and start time.
Exit conditions
| Condition | Exit code |
|---|---|
| All steps succeeded | 0 |
| SSH connection failed (remote targets only) | 1 |
| Instance directory not found | 1 |
deploy.yml is a YAML file where each top-level key is an instance_name. It centralises
per-instance defaults so commands can be invoked with only the instance name:
deploy update my-project # reads ssh_host, type, db, hooks from deploy.yml
deploy update my-project --db alt # overrides only the dbFull schema
# deploy.yml
odoo-myproject-production:
# Arguments
ssh_host: deploy@myserver.example.com # omit or set to "localhost" for local deployment
repo_url: git@github.com:org/repo.git # used by configure
# type: odoo # auto-detected from "odoo-" prefix; can be overridden
db: myproject # Odoo only; defaults to instance_name if omitted
# service / python only
exec_start: python -m myapp.main:app # `python -m module path` or `python file.py` or `fastapi entry` for python, can omit for server.py; verbatim for service
build: npm ci && npm run build # service only
# Hooks (update command)
hooks:
pre-update:
- ./scripts/check_disk_space.sh
pre-update-required:
- ./scripts/run_tests.sh
pre-update-success:
- ./scripts/notify_slack.sh "Pre-checks passed"
pre-update-fail:
- ./scripts/notify_slack.sh "Pre-checks failed"
post-update:
- ./scripts/smoke_test.sh
post-update-success:
- ./scripts/notify_slack.sh "Update succeeded"
post-update-fail:
- ./scripts/notify_slack.sh "Update failed"Precedence (highest → lowest): CLI argument → deploy.yml value → built-in default.
The config file is resolved locally (on the machine running deploy), not on the remote host.
It is not committed to the project repository — it lives outside the deployment folder, typically
alongside the operator's other deployment scripts or in a private configuration repository.
[Unit]
Description=Odoo instance {{ instance_name }}
After=network.target postgresql.service
[Service]
Type=simple
WorkingDirectory={{ instance_path }}
ExecStart=bash -c "{{ venv_path }}/bin/python {{ venv_path }}/bin/odoo \
--config {{ instance_path }}/config/odoo.conf \
--addons-path $({{ odoo_addons_path }} {{ instance_path }})"
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=default.target[Unit]
Description=Python service {{ instance_name }}
After=network.target
[Service]
Type=simple
WorkingDirectory={{ instance_path }}
ExecStart={{ venv_path }}/bin/{{ exec_start }}
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=default.targetGeneric template for non-Python services. exec_start is taken verbatim from deploy.yml.
[Unit]
Description={{ instance_name }}
After=network.target
[Service]
Type=simple
WorkingDirectory={{ instance_path }}
ExecStart={{ exec_start }}
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=default.targetdeploy/
├── deploy/
│ ├── __init__.py
│ ├── cli.py # Click entry point, registers commands
│ ├── command/
│ │ ├── configure.py # `configure` command
│ │ ├── update.py # `update` command
│ │ └── status.py # `status` command
│ ├── utils/
│ │ ├── executor.py # Executor abstraction: SSH (remote) or subprocess (local)
│ │ ├── venv.py # Virtual environment helpers (odoo-venv / standard)
│ │ ├── addons.py # odoo-addons-path integration
│ │ ├── config.py # deploy.yml loader and CLI arg merging
│ │ └── render.py # Jinja2 template rendering
│ └── templates/
│ ├── odoo.service.j2
│ ├── python.service.j2
│ └── service.service.j2
├── setup.py / pyproject.toml
└── README.md
# setup.py / pyproject.toml
entry_points={
"console_scripts": [
"deploy=deploy.cli:cli",
]
}| Package | Purpose |
|---|---|
click |
CLI framework |
jinja2 |
Template rendering |
pyyaml |
deploy.yml parsing |
All commands go through an executor abstraction (executor.py) that transparently runs
commands either via the system ssh binary (remote; respects ~/.ssh/config and agent
forwarding) or as local subprocesses when ssh_host is absent or localhost.
The following tools must be pre-installed on the target host:
| Tool | Required for |
|---|---|
odoo-venv |
odoo deployments |
odoo-addons-path |
odoo deployments |
uv |
python deployments |
click-odoo-contrib |
odoo venv (not a local dep of deploy) |
git-aggregator |
odoo deployments |
For service deployments, any additional runtime or toolchain (Node.js, Ruby, Rust, etc.) must
also be pre-installed; deploy only orchestrates git pull, the build command, and systemd
service management.