Reemote turns Python code into shell commands and runs them on many machines over SSH, in parallel. Think Ansible, but there is no CLI and no YAML: reemote is a **library* you import, await, and test. This walkthrough deploys two websites — a landing page and a Python web app — first on a local target, then on two remote servers, using only Python.*
We will stand up nginx, serve a static landing page, run a small Flask app behind it on a Unix socket, and finish with HTTPS from Let's Encrypt. Everything is ordinary Python: for loops, if statements, imports, types, and pytest.
This is a written walkthrough you can follow along at home. Every output
block labelled verified was produced by running the code shown against
a local ubuntu:24.04 container through reemote's docker connector; the
production-only steps (systemd, Certbot) are marked as such because a plain
container cannot run them, and the article says so rather than implying
otherwise.
What is reemote?
Reemote is a configuration-management library. You describe the state a machine should be in — packages installed, files present, services running — and reemote works out the shell commands that get it there, then runs them across your fleet concurrently.
A file full of those descriptions is a deploy. Here is the whole idea in a few lines:
from reemote import apt, files, op, server MOTD = "Managed by reemote\n" def deploy(host): for user in ("admin", "alice", "bob"): yield op(server.user, user=user) yield op(files.install, dest="/etc/motd", content=MOTD, sudo=True) if host.extra.get("install_postgres"): yield op(apt.packages, packages=("postgresql",), sudo=True) A deploy is a generator: it yields one op(...) request per action. op records the call — it does not run it. The runner drives the generator twice (once to plan, once to apply), which is why every argument must be hashable: packages=("postgresql",) is a tuple, not a list. host.extra is arbitrary per-host data from the inventory, so the if is just Python.
Why use reemote?
Reemote is declarative — you describe the target state, and reemote makes it so — and idempotent — nothing happens if the machine is already in that state. Those are the properties that make a deploy safe to re-run.
Unlike a YAML-driven tool, a deploy is Python code, so you get everything Python gives you:
- Your editor knows your code.
apt.packages,files.install, andsystemd.serviceare functions: go-to-definition, autocomplete, rename, and a type check that catches a typo before it reaches a production box. - No second templating language. There is no
{{ hostvars[...] }}stringly-typed escape hatch. A value is a Python expression, evaluated by the interpreter you already trust, and config text is rendered control-side with plain Python before you hand it tofiles.install. - Composition is
import. Roles are generator functions you call, loop over, andyield from— not a directory layout loaded by convention. - The result is data, not stdout.
run(...)returns structured objects; you readreport.log[i].changedinstead of regex-scraping a terminal. - Testing is
pytest. A deploy is a pure generator; you can assert on the requests it yields without a server in sight. -
awaitis the concurrency model. Hosts progress in parallel because the transport is asyncssh, not because a CLI forked workers you have to configure.
In fairness, reemote ships fewer built-in modules than a decade-old tool with a galaxy of roles. That is partly offset by how little code a custom operation takes — an async generator that reads a fact and yields a command, as the Certbot step later in this article shows.
When to use reemote?
Reemote's use cases follow from what it does: configure machines over SSH (or inside a container). It is a good fit for boring, cloud-agnostic architectures, from a fleet of servers with different roles all the way down to one-computer programming, where it still makes a setup reproducible and prevents drift.
It is easy enough to learn that it works well for self-hosting and home servers, and even when you do not want full configuration management, the Shell action gives you a convenient way to run ad-hoc commands across a fleet.
Simplicity has edges. There is no built-in pull mode, a fully automated pipeline is your own code around await run(...), and two people deploying at once need coordination. Reemote does not provision cloud resources — that is Terraform's job; reemote configures the machines once they exist. Staying "commands over SSH" is a deliberate scope, not a gap.
Install reemote
Reemote runs on your controller — the machine running the Python — and needs Python 3.13 or newer. You never install it on the target servers; they only need SSH (or, for this walkthrough, a container runtime).
Install from a git checkout. That is the recommended route here because the clone also gives you the runnable examples/ tree this article walks through:
git clone https://github.com/kimjarvis/reemote.git cd reemote uv sync source .venv/bin/activate uv sync creates .venv, installs reemote in editable mode and resolves its dependencies — asyncssh, distro, pydantic — so the SSH transport needs no separate step. uv is what we use; venv and pip work just as well. If you only want the library and not the examples, uv pip install reemote from PyPI is enough.
Every stage below has a complete, runnable script under examples/tutorials/website/, so you can read the article and run the code side by side:
| Article section | Example | What it does |
|---|---|---|
| Operations and facts · Where are the commands? | website_0.py | The two-phase model: read a fact, create a file |
| Inventory + nginx setup | website_1.py | A Shell action, then install and configure nginx |
| Static site + code reuse | website_2.py | Compose the base with a conditional landing-page site |
| Web app + secrets | website_3.py | Service accounts, a custom venv operation, rendered secrets |
| HTTPS + prod | website_4.py | A custom Certbot fact and operation, then the whole fleet |
website_0.py is the smallest complete program — the "basic part" the rest build on. Run it, and the others, with uv run from the checkout root:
uv run python examples/tutorials/website/website_0.py The examples target a local container named beta, so start it once — the walkthrough uses podman, and docker works identically:
podman run -d --name beta ubuntu:24.04 tail -f /dev/null Pointing the docker connector at a container name rather than an image leaves it running between runs, so its state persists like a VM — which is what makes idempotence visible. website_4.py is the exception: it targets two production SSH hosts and Certbot, so it builds and dry-runs offline but is meant to be pointed at your own servers.
The demo
At the end we will have two websites — a landing page and a Python web app — deployed in two stages.
First a single local target, beta, running both behind one nginx:
.------- beta --------.- app.beta --------. | nginx | | static website : Python web app | '------------------------------------------' ...then two separate production servers, one per site:
.-- web1 (prod) ---. .-- web2 (prod) ----. | nginx | | nginx | | static website | | Python web app | '------------------' '-------------------' Here is a sneak peek at the finished top-level deploy. It composes a shared nginx base with two conditional sub-deploys, then handles reloads as a second pass:
def deploy(host): yield from nginx_base(host) # apt + disable default site if host.extra.get("home_domain"): # static landing page yield from sub_deploy(home_site, name_prefix="home")(host) if host.extra.get("app_domain"): # Flask app behind gunicorn yield from sub_deploy(app_site, name_prefix="app")(host) The ifs read host.extra — static inventory data — so both phases of the run resolve them identically. That distinction (static data versus live facts) is the single most important rule in reemote, and the next two sections are about it.
Operations and facts
Two concepts do all the work.
Operations change the machine
An operation ensures some state: a file exists, a package is installed, a service is running. Most are declarative and idempotent. Here is a deploy with one operation that says "I want /tmp/hello.txt to exist":
# create_file.py import asyncio from reemote import files, format_log, format_plan, op, run INVENTORY = [{ "host": "beta", "connection": {"connector": "docker", "identifier": "beta", "docker_cmd": "podman"}, }] def deploy(host): yield op(files.file, name="create hello.txt", path="/tmp/hello.txt", present=True) async def main(): plans, report, _ = await run(INVENTORY, deploy) print("== plan ==") print(format_plan(plans)) if report is not None: print("== log ==") print(format_log(report)) asyncio.run(main()) beta here is a local container, so the walkthrough needs no SSH key and no VM. Start it once:
podman run -d --name beta ubuntu:24.04 tail -f /dev/null Pointing the docker connector at a container name (rather than an image) leaves it running between runs, so state persists exactly like a VM. Run the script:
python create_file.py Verified output — the plan says what will change, the log says what did and shows the command:
== plan == beta: create hello.txt [changed: /tmp/hello.txt missing, will create] == log == beta: create hello.txt [changed: /tmp/hello.txt missing, will create] $ touch /tmp/hello.txt Because files.file is idempotent, running the identical script again does nothing:
== plan == beta: create hello.txt [no change: file /tmp/hello.txt already exists] == log == beta: create hello.txt [no change: file /tmp/hello.txt already exists] There is no "press enter to execute" prompt — reemote is a library, not an interactive CLI. To look before you leap, pass dry=True: it runs the prepare phase only (facts are gathered for real, nothing mutates) and returns report=None. Preview with dry=True, then call run again without it to apply.
Facts read the machine
A fact gathers data about a target. Read one directly across hosts with gather_fact:
from reemote import Host, gather_fact, server hosts = [Host.from_inventory(entry) for entry in INVENTORY] names = await gather_fact(hosts, server.LinuxName) Verified output:
gather_fact LinuxName -> {'beta': 'Ubuntu'} The natural next step — pick a package name from the distribution — is where reemote differs most from a tool that lets you branch on a fact mid-deploy. You cannot. A deploy runs twice (prepare, then apply); if its body branched on a live fact, the two passes could diverge, so the runner raises a DeterminismError rather than silently applying a plan it did not show you. Static inventory data (host.name, host.group, host.tags, host.extra) is identical in both phases and safe to branch on; facts are not.
So fact-driven choice moves out of the deploy, into a gather → snapshot → factory shape:
from reemote import apt, gather_fact, op, run, server # 1. Gather the fact up front, across every host, and freeze it. names = await gather_fact(hosts, server.LinuxName) # 2. Build a per-host dispatch table from the snapshot (keyed by host name). PACKAGE = {"Ubuntu": "apache2", "Debian": "apache2"} def build_install(name): package = PACKAGE.get(name, "httpd") def install(host): yield op(apt.packages, name="install apache", packages=(package,), sudo=True) return install # 3. Run each host against a deploy chosen from its frozen value — never live. for entry in INVENTORY: await run([entry], build_install(names[entry["host"]])) The branch happens in ordinary Python before the deploy exists, so each deploy body is branch-free and deterministic. It is a little more code than an inline if, and in exchange the plan reemote shows you is a plan it will actually honour.
Where are the commands?
An operation is an async generator that takes an OpContext, reads facts to decide whether anything must change, and yields command descriptors only when it does. This is a simplified files.file:
from reemote import OpContext, StringCommand async def file(ctx: OpContext, path: str, present: bool = True): exists = await ctx.facts.get(FileExists, path) if not exists and present: ctx.changed = True ctx.reason = f"{path} missing, will create" yield StringCommand(f"touch {path}") elif exists and not present: ctx.changed = True ctx.reason = f"{path} present, will remove" yield StringCommand(f"rm -f {path}") The shape is the same for every operation: read state with a fact, diff it against the desired state, set ctx.changed/ctx.reason, and yield the commands that close the gap. Yield nothing and the operation reports "no change".
Here is a deploy that says "I want /tmp/hello.txt to not exist":
def deploy(host): yield op(files.file, name="delete hello.txt", path="/tmp/hello.txt", present=False) format_log renders the command descriptors that were actually sent, which is where the shell text finally appears. Verified output (against the container that still has the file from earlier):
== plan == beta: delete hello.txt [changed: /tmp/hello.txt present, will remove] == log == beta: delete hello.txt [changed: /tmp/hello.txt present, will remove] $ rm -f /tmp/hello.txt The plan comes from the prepare phase: reemote runs each operation to see what it would yield, gathering facts (read-only commands) but sending nothing that mutates. The log comes from the apply phase: the same operations run again, and this time their commands are sent. Apply re-creates the fact cache before every step, so a later operation sees the effect of an earlier one — install a package in step 1 and step 2's package fact reads the newly-installed set.
Inventory + nginx setup
So far the target has been a local container, but we want beta to behave like production. The inventory is just a Python list of dicts — no separate file format to learn. Each entry names a host, its connection, and any extra data:
BETA = [{ "host": "beta", "connection": {"connector": "docker", "identifier": "beta", "docker_cmd": "podman"}, }] # Production is the same shape over the default ssh connector — the # "connector" key can be omitted, since ssh is the default: PROD = [{ "host": "web1", "connection": {"address": "web1.example.com", "username": "deploy", "key_path": "~/.ssh/id_ed25519"}, "home_domain": "www.example.com", "stage": "prod", "nginx_https": True, }] Because the inventory is Python, a group does not have to be a literal — build it from a database query or your cloud's SDK ([i.hostname for i in ...]). The keys a Host does not recognise (home_domain, stage, nginx_https) are preserved verbatim in host.extra, which is how per-host data reaches the deploy.
To run an arbitrary command across the fleet — the ad-hoc "what version is this?" move — use the Shell action. Actions run immediately, outside the deploy model, and hand you the result as data:
from reemote import Host, Shell, gather_action hosts = [Host.from_inventory(e) for e in BETA] results = await gather_action(hosts, Shell, command='. /etc/os-release && echo "$PRETTY_NAME"') Verified output:
Shell beta: exit=0 stdout='Ubuntu 24.04.5 LTS' An action's exit status is data, not an exception — even 127 (command not found) comes back in exit_status so the caller decides what it means. Actions cannot run inside a deploy; gather_action raises the moment it is called from a generator.
Point it at a real server
The beta container needs no keys and no network, but the same deploys run against a real machine unchanged — only the inventory entry differs. To add a server you can SSH into, give its entry a connection with an address, a username and a key_path instead of the docker connector:
REAL = [{ "host": "web1", # any name you like "connection": { "address": "web1.example.com", # or an IP "username": "deploy", # the login user "key_path": "~/.ssh/id_ed25519", # its private key }, # Any other keys land in host.extra and drive the conditional deploys: "home_domain": "www.example.com", "stage": "prod", }] connection keeps asyncssh's own option names, so port, password, passphrase, proxy_command and the rest pass straight through; address, username and key_path are friendly aliases for the target, the login name and a single identity key. Add more dictionaries to the list to add more hosts — run(REAL, deploy) fans the same deploy across all of them in parallel.
Two things change when you leave the container:
- Elevation. The
betaexamples run as the container'sroot, so they omitsudo. A real login user is not root, so passsudo=Trueon each request that writes a system path — exactly aswebsite_4.pyand the prod snippets here do. Reemote renderssudo <command>and never prompts: grant the login user passwordlesssudo, and set any run-as user or password in the entry's top-levelprivilegeblock (sudo_user,sudo_password), never in the operation. - Reachability. Host-key checking is on by default; for a first connection to a new host either pre-seed
~/.ssh/known_hostsor pass"known_hosts": Noneinconnectionto skip the check.
Swap INVENTORY for REAL in any example and it deploys to your server instead of beta — the generator functions are identical, because a connector only overrides the transport, never the deploy.
Installing nginx
Both websites run behind nginx, so install it first. This is the shared base every host gets:
from reemote import apt, files, op def nginx_base(host): yield op(apt.update, name="apt update") yield op(apt.packages, name="install nginx", packages=("nginx",)) yield op(files.link, name="disable default site", path="/etc/nginx/sites-enabled/default", present=False) Run it against beta and the plan and log line up. Verified output, first run:
== plan == beta: apt update [changed: apt update] beta: install nginx [changed: 1 package(s) to install] == log == beta: apt update [changed: apt update] $ apt-get update beta: install nginx [changed: 1 package(s) to install] $ DEBIAN_FRONTEND=noninteractive apt-get -y -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" install nginx Run it again and it converges — except apt.update, which is deliberately stateless and always runs (there is no cache_time stamp file, because a timestamp would drift between the prepare and apply phases and break the determinism guard). Verified second run:
== plan == beta: apt update [changed: apt update] beta: install nginx [no change: all packages present] == log == beta: apt update [changed: apt update] $ apt-get update beta: install nginx [no change: all packages present] Disabling the default "Welcome to nginx!" site removes a symlink, and its plan reports exactly that. Verified output:
== plan == beta: disable default site [changed: /etc/nginx/sites-enabled/default present, will remove] == log == beta: disable default site [changed: /etc/nginx/sites-enabled/default present, will remove] $ rm -f /etc/nginx/sites-enabled/default Notice the ordering worked without any special handling. "Disable the default site" depends on nginx having created that symlink — and it did, because apply runs in lockstep, one request index at a time, so the install completes before the link step reads its fact. You might be tempted to gate the link on a fact instead:
# Do NOT do this — it cannot work, and reemote will not let it. if some_link_fact: # a live fact read inside the deploy body yield op(files.link, ...) That branch reads a fact during prepare, when nginx is not installed yet, so the symlink would not exist and the step would be skipped on the very first run. Reemote forecloses the mistake entirely: branching on a live fact in a deploy raises DeterminismError. The lockstep apply is what makes "install, then configure what the install created" safe to write as two plain steps in order.
A config change usually needs a reload. Reemote has no notify/handler keyword; a conditional reload is built from the results of a previous run, in ordinary Python, between two deploys — the two-deploy split:
from reemote import changed_hosts, op, run, systemd # Pass 1: converge the nginx config (the "notifying" tasks). _, report, _ = await run(BETA, nginx_base) # Freeze the hosts whose named operations actually changed. changed = changed_hosts(report, {"disable default site"}) # Pass 2: reload nginx, only on those hosts. def reload_nginx(host): if host.name in changed: yield op(systemd.service, name="reload nginx", service="nginx", reloaded=True, sudo=True) await run(BETA, reload_nginx) changed is a fixed value by the time pass 2 builds its deploy, so the branch is on a snapshot — never a live fact — and stays deterministic. An empty changed yields an empty deploy: no host reloads. This is the whole notification mechanism, and it needs no handler-ordering rules to memorise.
!!! note "systemd needs a real init"
The reload above (and the service steps later) drive `systemctl`, which a stock `ubuntu:24.04` container does not run as PID 1 — `systemctl` refuses with "System has not been booted with systemd as init system". The apt/files/user output in this article is verified against the container; the systemd steps are shown as the code and the plan/log a systemd-enabled host (a real VM, bare metal, or a `jrei/systemd-ubuntu` image) renders. On production this just works. Static site + code reuse
With nginx installed, the landing page is next. Keep its operations in a reusable function and include it conditionally, so a host only gets a site if its inventory says so:
def deploy(host): yield from nginx_base(host) if host.extra.get("home_domain"): yield from sub_deploy(home_site, name_prefix="home")(host) host.extra is static inventory data, so this if is deterministic — both phases resolve it the same way. sub_deploy(..., name_prefix="home") labels every request the sub-deploy yields with a home: prefix, which is how the plan keeps a composed deploy readable.
Two honest adaptations from a YAML-and-Jinja workflow:
- There is no
files.syncand no controller-filesystem awareness. Reemote never resolves a local directory path on the target's behalf; content crosses as data. To publish a folder of static files, read them control-side and install each one:
from pathlib import Path from reemote import files, op def home_site(host): root = Path("site") # read on the control machine for path in sorted(root.rglob("*")): if path.is_file(): rel = path.relative_to(root) yield opSource:https://dev.to/kimbofab/deploying-websites-with-reemote-from-a-local-container-to-production-32ooRelated posts
What's new in Pyroscope: profile more of your workflows, troubleshoot with AI agents, and more

GitHub Actions has lockfiles now, and pull_request_target gets blocked on Dec 2: 7 workflow changes to catch in review in 2026
The 7-Second Rule: What Visitors Decide About Your Website Before They Read a Word
Black Friday code freeze: what design can still change
Building ERPSav: Vendor Master Duplicates Are an Entity Resolution Problem, What Worked, What Broke
