Skip to content
shellhacks.dev
Python

How to Replace a Bash Deploy Script With Python Step by Step

Replace a Bash deploy script with Python: subprocess with check=True, release directories and symlink swaps, health checks, rollback and clear logging.

7 min read
Summarize in AI
Illustration for "How to Replace a Bash Deploy Script With Python Step by Step": a shell script in an editor, a directory tree, text before and after a transform.

Deploy scripts in Bash tend to grow set -e exceptions, a rollback trap and quoting fixes until nobody wants to edit them. The steps themselves are simple, which makes this a good candidate for a rewrite that is easier to read and test. The pattern it implements is described in atomic deploys with rsync and symlinks, and the examples assume Python 3.13 on Linux.

The skeleton

Python
#!/usr/bin/env python3
import logging
import os
import subprocess
import sys
import urllib.request
from datetime import datetime, timezone
from pathlib import Path

APP = Path("/srv/app")
RELEASES = APP / "releases"
CURRENT = APP / "current"
log = logging.getLogger("deploy")


def run(*args: str) -> None:
    log.info("$ %s", " ".join(args))
    subprocess.run(args, check=True)


def switch(release: Path) -> Path | None:
    previous = CURRENT.resolve() if CURRENT.is_symlink() else None
    tmp = APP / "current.new"
    tmp.unlink(missing_ok=True)
    tmp.symlink_to(release)
    os.replace(tmp, CURRENT)
    return previous


def healthy(url: str, tries: int = 10) -> bool:
    import time
    for _ in range(tries):
        try:
            with urllib.request.urlopen(url, timeout=3) as response:
                if response.status == 200:
                    return True
        except OSError:
            pass
        time.sleep(2)
    return False

run logs the command and raises CalledProcessError on a non-zero exit, so no step can fail quietly. The command is a list of arguments, so there is no shell involved and no injection from a variable. os.replace on a symlink is an atomic rename, and the server never sees a missing current.

The deploy function

Python
def deploy(artifact: Path) -> int:
    stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
    release = RELEASES / stamp
    release.mkdir(parents=True)
    run("tar", "-xzf", str(artifact), "-C", str(release))
    run("python3", "-m", "venv", str(release / ".venv"))
    run(str(release / ".venv/bin/pip"), "install", "-r", str(release / "requirements.txt"))
    previous = switch(release)
    run("systemctl", "restart", "app.service")
    if healthy("http://127.0.0.1:8080/health"):
        log.info("deployed %s", stamp)
        return 0
    log.error("health check failed, rolling back")
    if previous:
        switch(previous)
        run("systemctl", "restart", "app.service")
    return 1


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
    sys.exit(deploy(Path(sys.argv[1])))
Text
2026-11-03 10:02:11,203 $ tar -xzf build.tgz -C /srv/app/releases/20261103100211
2026-11-03 10:02:19,880 $ systemctl restart app.service
2026-11-03 10:02:25,917 deployed 20261103100211

The sample log shows the shape of the output. The rollback is a second call to switch, which is why keeping old releases pays off: going back takes a second. Prune old release directories after a successful deploy, keeping the last five, as in rotating backups. The full policy is in rolling back a failed deploy.

Secrets and safety

Read secrets from the environment or a root-only file, never from arguments, which appear in ps. Take a lock so two deploys cannot overlap: fcntl.flock on a lock file works in Python the way flock does in shell. Run migrations as a separate, explicit step before the switch, with their own failure handling; see migrations in a deploy script. Keep the script itself in version control with the application.

What Python gives a deploy script that Bash does not

The argument for Python is mostly about failure handling. In Bash, error handling is a set of conventions you opt into, such as set -e, pipefail and traps, each with edge cases that surprise people. In Python an unhandled failure is an exception with a traceback, and every call to subprocess.run(check=True) raises when a command fails. The default is to stop loudly, which is the right default for something that changes a production server.

Structure is the other gain. A deploy has distinct phases: fetch the artifact, unpack it, install dependencies, run checks, switch the pointer, restart the service, verify. Each can be a small function with a name, a docstring and a unit test. A function that builds the release path from a timestamp can be exercised byout touching a server, which is hard to do for the same logic embedded in forty lines of shell.

Health checks and automatic rollback

A deploy that ends when the service restarts has not verified anything. After the switch, the script should wait for the application to answer a health endpoint, with a timeout, and treat a failure as a reason to switch back. The loop is short: poll the URL every second, succeed on the first HTTP 200, give up after a fixed number of attempts, and on giving up point the symlink at the previous release and restart again.

Keep the rollback path as boring as the deploy path. It should be the same switch function called with the old release, not a separate procedure that only runs in emergencies and therefore never gets tested. Record which release was live before the switch, usually by reading the symlink target with os.readlink, so the script knows where to go back to without guessing from directory names.

Database changes complicate this. A rollback of code is cheap, while a rollback of a schema may be impossible. The usual discipline is to make migrations backward compatible for one release, so the old code still works against the new schema, and to run them before the switch. The details are in database migrations in a deploy script.

Running it from CI and keeping it honest

The script is only as trustworthy as the way it is invoked. From CI, run it over SSH with a key that can do nothing else, ideally restricted in authorized_keys to a single forced command. Pass the artifact path as an argument and keep credentials out of the command line, since arguments are visible to every user who can read the process table.

Add a --dry-run option that prints each step instead of running it. It costs a few lines, because every external call already goes through one run helper, and it lets a reviewer see what a change to the script will do before it touches a server. Log to a file in the release directory as well as to the terminal, so the record of what happened survives the SSH session that started it.

Testing the deploy logic without a server

The point of moving a deploy into Python is that its logic can be checked on a laptop. The pieces that matter are pure or nearly pure: building a release path from a timestamp, choosing which old releases to prune, deciding whether a health check passed. Test those directly with ordinary assertions. For the parts that touch the system, pass the root directory as a parameter so a test can point the script at a temporary directory and watch it create releases and swap the symlink.

Replace the command runner, not the standard library. If every external command goes through one function, a test substitutes a fake that records calls, and the assertions read as a list of expected commands in order. That catches mistakes like restarting the service before the switch, which only show up in production otherwise.

Keep one end-to-end check that runs the real thing against a throwaway directory and a dummy service, such as a small HTTP server on a local port. It is slower, but it proves that the symlink swap, the restart call and the health poll cooperate. Run it in CI on every change to the script.

Finish by deciding who may run the script and from where. A deploy that any developer can start from a laptop with production credentials is a different risk from one that only CI can start, and the answer shapes how secrets are stored. Write the rule in the repository next to the script, so the next person to change it knows what the design assumes.

Where this does not apply

A deploy that is git pull && systemctl restart app on one server is fine as two lines of shell. When you manage many hosts or need idempotent state, an orchestrator or Ansible, discussed in replacing SSH loops, is a better home. The script above needs Python on the host and a virtual environment build that can fail on network access, so build artifacts in CI instead where you can. See the subprocess documentation and os.replace.

Also inDevOps
Share

Keep reading

Get new posts by email

One email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.