A small library for deploying Python applications to Linux servers over SSH. Plain Python functions on top of Fabric: no agents on the server, no YAML, no DSL to learn. Your deploy script reads top to bottom.
It doesn't try to compete with Ansible or Docker. If you have a few servers, you write Python, and you want your deploy to be just another deploy.py in your project, it might be for you.
from pyeasydeploy import (
SupervisorService, connect_to_host, create_venv,
deploy_supervisor_service, get_target_python_instance,
install_local_package, supervisor_restart,
)
APP = "myapp"
USER = "deploy"
conn = connect_to_host(
host="203.0.113.10",
user=USER,
key_filename="~/.ssh/id_ed25519",
sudo_password="...", # better: os.environ["SUDO_PASSWORD"]
)
py = get_target_python_instance(conn, "3.11")
venv = create_venv(conn, py, f"/home/{USER}/venvs/{APP}")
install_local_package(conn, venv, f"./{APP}")
deploy_supervisor_service(conn, SupervisorService(
name=APP,
command=f"{venv.venv_path}/bin/python -m {APP}",
directory=f"/home/{USER}",
user=USER,
))
supervisor_restart(conn, APP)Connect, pick an interpreter, create the venv, install your package with its dependencies, and leave it running as a supervised service that survives reboots. The venv object returned by create_venv carries its own path: the service command is built from it, no paths repeated by hand.
Destructive and reproducible. Uploads remove the destination and copy from scratch, every time; venvs are recreated, not reused. After each deploy, the server has exactly what your script says it should have — no leftovers from previous versions. (The one safety net: paths like /, /home or /etc are rejected before anything is removed.) See Reproducibility for how far that guarantee reaches.
Fail early, fail clearly. Models validate on construction: a relative path or a service name that would corrupt the INI file blows up on your laptop with a useful message, before touching the server. Functions that need sudo check for it upfront — an immediate error with instructions, instead of the classic hang waiting for a password that will never come.
Trust the user. The library validates form (types, absolute paths, dangerous characters), not your facts: if you hand-build a PythonInstance pointing at an exotic interpreter, it's accepted. You know what's on your server.
pip install pyeasydeployPython ≥ 3.10 on your machine. On the server: SSH and some python3 (tested on Debian/Ubuntu).
conn = connect_to_host(host, user, password="...") # password (reused for sudo)
conn = connect_to_host(host, user, key_filename="~/.ssh/id_ed25519") # SSH keyWith key auth and sudo operations, add sudo_password=. The connection is lazy: a wrong password shows up on the first command, not at connect time.
py = get_any_python_instance(conn) # newest on the server
py = get_target_python_instance(conn, "3.11") # a specific oneOnly real interpreters are matched (python3.X-config and friends are filtered out), and version matching is component-wise: "3.1" means 3.1, not 3.11. For non-standard locations, build the model yourself:
py = PythonInstance(version="3.12", executable="/opt/py312/bin/python3.12")venv = create_venv(conn, py, "/home/deploy/venvs/myapp") # wiped and rebuilt
install_packages(conn, venv, ["fastapi", "uvicorn[standard]"])
install_local_package(conn, venv, "./myapp")
install_package_from_private_github(conn, venv, "git@github.com:org/private.git")
run_in_venv(conn, venv, "python -m myapp --check")create_venv deletes the existing environment and builds a new one. If you relied on reuse, pass recreate=False explicitly.
venv = create_venv(conn, py, "/home/deploy/venvs/myapp", recreate=False)
install_local_package(conn, venv, "./myapp", force=True) # see belowReuse is for development, when reinstalling a large environment on every run is expensive. It comes with a catch: pip compares versions, not commits, so new code shipped under the same version number is silently ignored and the server keeps running the old one. force=True (available on all four install_* functions) passes --force-reinstall and fixes it. With the default recreate=True you don't need it.
Installs use uv inside the venv (fast; use_uv=False for classic pip). Private repos are cloned on your machine with your own credentials, then the source is uploaded: the server never needs access to your GitHub.
upload_directory(conn, "./data", "/home/deploy/data")
upload_file(conn, "config.toml", "/home/deploy/myapp/config.toml")
upload_directory(conn, "./data", "/home/deploy/data", mode=0o644) # every file
upload_file(conn, "secrets.env", "/home/deploy/myapp/.env", mode=0o600).git, __pycache__, venvs and similar are excluded by default (DEFAULT_IGNORE); pass ignore=[] to upload everything.
Without mode, permissions are whatever SFTP decides, which depends on the machine you deploy from — pass it when two people deploying the same project must get the same result. In upload_directory it applies to every file in the tree; directories keep the remote umask.
install_supervisor(conn) # once per server
deploy_supervisor_service(conn, SupervisorService(
name="myapp",
command=f"{venv.venv_path}/bin/python -m myapp",
extra={
"stdout_logfile_maxbytes": "10MB", # any supervisord option,
"stdout_logfile_backups": 5, # passed through verbatim
"stopsignal": "INT",
},
))
supervisor_restart(conn, "myapp")
supervisor_status(conn)Named fields cover the common cases; the extra dict accepts any supervisord option with no restrictions — the library only blocks what would corrupt the generated file.
The goal: after a deploy, the parts of the server the library owns are a function of your script, not of what was there before. Run the same script twice, or run it against a fresh server, and you get the same result.
What that covers:
- Uploads.
upload_fileandupload_directoryremove the destination first. The remote tree is exactly your local tree minus the ignored patterns. Addmode=and the permissions stop depending on the machine you deploy from too. - Venvs.
create_venvwipes and rebuilds by default. Packages you stopped declaring disappear, pinned versions really apply, and changing the target Python version actually changes the interpreter — none of which happens in a reused venv. - Services. The
.conffor a deployed service is rewritten from theSupervisorServicemodel every time. What you declare is what supervisord reads.
What it does not cover — real gaps, not oversights:
- System packages and OS state. apt, users, nginx, databases, firewall, cron. Out of scope; the library doesn't touch them (the one exception is
install_supervisor, because services are its job). - Files the app creates at runtime. Databases, logs, uploads, caches. They live wherever your app puts them and survive every deploy — which is normally what you want. If one lands inside an upload destination, it gets wiped: keep runtime data outside deploy directories.
- Services deployed by previous runs.
deploy_supervisor_servicemanages the service you hand it and nothing else. Services from earlier runs stay untouched, and stay running.
There is no prune. If you rename a service — say myapp becomes myapp-web — the new .conf is deployed and started, and the old myapp keeps running with the old code, from a venv you may have just rebuilt underneath it. Same if you drop a service from your script: it isn't removed, it just stops being managed.
A deploy_supervisor_services(services, prune=True) that deleted every .conf not declared would be the coherent thing to do, but on a host shared with other apps it would take down services this library never deployed. Too much blast radius for now. Until then, removing a service is manual:
conn.sudo("supervisorctl stop myapp")
conn.sudo("rm /etc/supervisor/conf.d/myapp.conf")
conn.sudo("supervisorctl update")- Not Ansible/Terraform. No inventories, no state, no declarative idempotency. Imperative on purpose.
- Not provisioning. It installs supervisor because services are its job, and that's where it stops: nginx, databases and the rest of your server are up to you.
- No secret management. The passwords you pass in are your environment's responsibility.
- No fleet orchestration. One connection, one server. For several, write a loop.
- Linux targets only. The source machine can be Windows, macOS or Linux.
For many of those cases, bigger tools will do it better. This one exists for when you don't need them.
MIT