{"slug": "pyinfra-simple-infrastructure-automation-in-python", "title": "Pyinfra – simple infrastructure automation in Python", "summary": "Adrian, a Python developer and maintainer of the reader feed library, demonstrated pyinfra, an open-source infrastructure automation tool that turns Python code into shell commands and runs them on many servers over SSH in parallel, at a local DevOps meetup in November 2026. The demo deployed two websites — a landing page and a Python web app — first on a local virtual machine and then on two remote servers using only Python code. Adrian said pyinfra is declarative and idempotent like Ansible but uses Python instead of YAML, and in his limited small-scale testing it was faster than Ansible with the Mitogen plugin, though pyinfra has fewer built-in operations and a smaller ecosystem after 10 years.", "body_md": "# pyinfra – simple infrastructure automation in Python\n\nNovember 2026 ∙ 15 minute read ∙\n\nHi, I'm Adrian,\nand today I will live demo **[pyinfra](https://pyinfra.com/)**,\nan infrastructure automation tool.\n\nWe will deploy two websites – a landing page and a Python web app – first on a local virtual machine, and then on two remote servers, using only Python code.\n\nNote\n\nThis is the edited transcript of a talk I gave at a local DevOps meetup. While part of the \"live\" aspect is lost in writing, I think it's a pretty decent pyinfra tutorial, so you are welcome to follow along at home.\n\n## About me [#](#about-me)\n\nBefore we talk about pyinfra, I should [introduce myself](https://death.andgravity.com/about).\n\nI worked at a Fortune 10 company for most of my career, where I did site reliability engineering for the reverse proxy behind some really big websites and two thousand smaller ones, and where I built a data-lake-plus-feature-store for an ML organization.\n\nIn my spare time,\nI write about Python on [this website](https://death.andgravity.com/),\nand I maintain the creatively named *[reader](https://github.com/lemon24/reader)*,\na Python feed reader library and web app.\nLately I've been working on a\n[hosted version of *reader*](https://death.andgravity.com/reader-3-23#hosted-reader-status-update),\nwhich is how I ended up using pyinfra.\n\n## What is pyinfra? [#](#what-is-pyinfra)\n\n[pyinfra](https://pyinfra.com/) is a mostly command-line tool that\nturns Python code into shell commands\nand runs them on many servers over SSH, in parallel.\nThink Ansible, but simpler.\n\n```\nfor user in ['admin', 'alice', 'bob']:\n    server.user(user)\n\nfiles.put('files/hello.txt', '/etc/motd')\n\nif host.data.get('install_postgres'):\n    apt.install('postgresql')\n```\n\n## Why use pyinfra? [#](#why-use-pyinfra)\n\nI chose pyinfra because, like [Ansible](<https://en.wikipedia.org/wiki/Ansible_(software)>), it is\n*declarative* –\nyou describe what a server should look like, and pyinfra makes it so\n– and *idempotent* –\nnothing happens if the server is already in the desired state.\n\nUnlike Ansible, there's no YAML – *deploys* (playbooks) are Python code,\nwhich means you can do anything Python code can do:\nyou can have `for` s and `if` s,\nyou can have code completion and type checking,\nand you can even import libraries.\n\nOf interest to people using Ansible at larger scales,\npyinfra is also faster;\nin my limited, small-scale experience,\nit was faster than Ansible with the [Mitogen](https://mitogen.networkgenomics.com/ansible_detailed.html) plugin.\n\nIn fairness to Ansible,\nwhile pyinfra has been around for 10 years,\nit has fewer built-in *operations* (modules) and a smaller ecosystem,\nalthough that's partially offset\nby how easy it is to write custom operations.\n\n## When to use pyinfra? [#](#when-to-use-pyinfra)\n\npyinfra use cases follow from what it does – configuring servers over SSH.\n\nAs such, pyinfra is good for boring,\ncloud-agnostic architectures,\nfrom lots of servers with different roles\nall the way down [one-computer programming](https://crawshaw.io/blog/one-process-programming-notes),\nwhere it still makes deploys reproducible and prevents drift.[1](#fn-1)\n\nIt is pretty easy to learn too,\nso it's a good fit for self-hosting and home servers,\nand even if you don't use it for configuration management,\npyinfra gives you a convenient way of running ad-hoc commands\non fleets of servers.[2](#fn-2)\n\nThe simplicity comes with limitations:\nfully-automated pipelines are extra work,\nand care is needed\nif multiple people run deploys at the same time.\nNevertheless,\nyou can get pretty far if you structure your deploys well,\nand there's a [Python API](https://docs.pyinfra.com/en/3.x/api/index.html) for building stuff on top.\n\n## The demo [#](#the-demo)\n\nI'll try to focus on the interesting parts,\nbut you can find the entire code on [GitHub](https://github.com/lemon24/pyinfra-demo).\n\nAt the end of the demo, we'll have two websites, a landing page and a Python web app, deployed in two stages – a local virtual machine for beta:\n\n```\n.--- pyinfra.local ---.- app.pyinfra.local -.\n|                   nginx                   |\n|   static website    :   Python web app    |\n'-------------------------------------------'\n```\n\n...and two separate servers for prod:\n\n```\n.--- pyinfra.a.c ----. .- app.pyinfra.a.c --.\n|       nginx        | |       nginx        |\n|   static website   | |   Python web app   |\n'--------------------' '--------------------'\n```\n\n## Here's a sneak peek of the final deploy:\n\n```\napt.update(name=\"apt update\", cache_time=24 * 3600)\n\napt.packages(['nginx', 'python3-certbot-nginx'], name=\"install nginx\")\n\ndisable_default = files.link(\n    name=\"disable default site\",\n    path='/etc/nginx/sites-enabled/default',\n    present=False,\n)\nserver.service(\n    name=\"reload nginx\",\n    service='nginx',\n    reloaded=True,\n    _if=disable_default.did_change,\n)\n\nif host.data.get('home_domain'):\n    local.include('tasks/home.py')\nif host.data.get('app_domain'):\n    local.include('tasks/app.py')\n```\n\n## Operations and facts [#](#operations-and-facts)\n\nFirst, we should get familiar with two basic pyinfra concepts.\n\n**Operations** change the server in some way;\nmost are declarative and idempotent,\nand you use them to *ensure state*.\nA Python file containing operations is called a **deploy**.\n\nHere's a simple deploy with [one operation](https://docs.pyinfra.com/en/3.x/operations/files.html#files-file)\nthat says \"I want `hello.txt` to exist\":\n\n``` python\nfrom pyinfra.operations import files\n\nfiles.file('hello.txt', present=True)\n```\n\nIf we run this deploy (below, on my `@local` machine),\nwe see a list of all the operations\nand on which hosts they will produce changes,\nand are asked to approve them:\n\n``` php\n$ pyinfra @local examples/create_file.py\n--> Loading config...\n--> Loading inventory...\n--> Connecting to hosts...\n    [@local] Connected\n\n--> Preparing operation files...\n    Loading: examples/create_file.py\n    [@local] Ready: examples/create_file.py\n\n--> Detected changes:\n    Operation                              Change       Conditional Change\n    files.file (hello.txt, present=True)   1 (@local)   -\n\n    ...\n    Press enter to execute...\n```\n\nIf we do, we see the operations running and their outcomes:\n\n``` php\n    Press enter to execute... [Enter]\n--> Beginning operation run...\n--> Starting operation: files.file (hello.txt, present=True)\n    [@local] Success\n\n--> Results:\n    Operation                              Hosts   Success   Error   No Change\n    files.file (hello.txt, present=True)   1       1         -       -\n    Grand total                            1       1         -       -\n\n--> Disconnecting from hosts...\n```\n\nAnd now `hello.txt` exists:\n\n``` bash\n$ ls hello.txt\nhello.txt\n```\n\nSince [files.file](https://docs.pyinfra.com/en/3.x/operations/files.html#files-file) is idempotent,\nnothing happens if we run the deploy again:\n\n``` php\n$ pyinfra @local examples/create_file.py\n...\n--> Detected changes:\n    Operation                              Change   Conditional Change\n    files.file (hello.txt, present=True)   -        -\n...\n--> Beginning operation run...\n--> Starting operation: files.file (hello.txt, present=True)\n    [@local] No changes\n...\n```\n\n**Facts** gather data about the target host;\nyou use them to control operations.\n\nHere's a deploy that installs the Apache web server:\n\n``` python\nfrom pyinfra import host\nfrom pyinfra.facts.server import LinuxName\nfrom pyinfra.operations import server\n\nif host.get_fact(LinuxName) in ['Ubuntu', 'Debian']:\n    server.packages('apache2')\nelse:\n    server.packages('httpd')\n```\n\nThe package is usually called `httpd`,\nbut Debian-based distributions call it `apache2`, so\nwe use the [LinuxName](https://docs.pyinfra.com/en/3.x/facts/server.html#server-LinuxName) fact to detect where we are\nand pass the right name to the [server.packages](https://docs.pyinfra.com/en/3.x/operations/server.html#server-packages) operation.\nMy machine is a Mac,\nso the `httpd` branch is chosen:[3](#fn-3)\n\n``` php\n$ pyinfra @local examples/install_apache.py --dry\n...\n--> Detected changes:\n    Operation                 Change       Conditional Change\n    server.packages (httpd)   1 (@local)   -\n...\n```\n\n## Where are the commands? [#](#where-are-the-commands)\n\nBefore we look at debug output, let's see how pyinfra operations are implemented.\n\nHere's a simplified version of the files.file [implementation](https://github.com/pyinfra-dev/pyinfra/blob/v3.10.0/src/pyinfra/operations/files.py#L1663-L1762):\n\n``` python\n@operation()\ndef file(path: str, present: bool = True):\n    info = host.get_fact(File, path=path)\n\n    if info is None and present:\n        yield f\"touch {quote(path)}\"\n\n    elif info and not present:\n        yield f\"rm -f {quote(path)}\"\n```\n\nThe structure is similar for all operations: read the host state using facts, diff it against the desired state, and return commands to reach the desired state if they don't match.\n\nHere's another deploy\nthat says \"I want `hello.txt` to *not* exist\":\n\n``` python\nfrom pyinfra.operations import files\n\nfiles.file('hello.txt', present=False)\n```\n\nWith `--debug`,\nwe can see the commands being run\nduring each execution phase.\n\nDuring the *prepare* phase (before approval),\npyinfra runs the deploy code *once per host* to load operations,\nand then runs each operation to see if it returns commands,\n*without running the commands themselves*\n(this is how \"detected changes\" works).\n\nRunning operations runs facts, which results in read-only commands being run:\n\n``` php\n$ pyinfra @local examples/delete_file.py --debug\n...\n--> Preparing operation files...\n    Loading: examples/delete_file.py\n    [pyinfra.api.operation] Adding operation, {'files.file'}, ...\n    [pyinfra.api.facts] Getting fact: files.File (path=hello.txt)\n[pyinfra.connectors.local] --> Running command on localhost: sh -c '! (test -e hello.txt || test -L hello.txt ) || ( stat -c '\"'\"'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N'\"'\"' hello.txt 2> /dev/null || stat -f '\"'\"'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY'\"'\"' hello.txt || ls -ld hello.txt )'\n    [pyinfra.api.facts] [@local] Loaded fact files.File (path=hello.txt)\n    [@local] Ready: examples/delete_file.py\n\n--> Detected changes:\n    Operation                               Change       Conditional Change\n    files.file (hello.txt, present=False)   1 (@local)   -\n\n    ...\n    Press enter to execute...\n```\n\nDuring the *execute* phase,\npyinfra runs the previously loaded operations *again*,\nbut this time it runs the commands returned by them too:\n\n``` php\n    Press enter to execute... [Enter]\n--> Beginning operation run...\n--> Starting operation: files.file (hello.txt, present=False)\n    [pyinfra.api.facts] Getting fact: files.File (path=hello.txt)\n[pyinfra.connectors.local] --> Running command on localhost: sh -c '! (test -e hello.txt || test -L hello.txt ) || ( stat -c '\"'\"'user=%U group=%G mode=%A atime=%X mtime=%Y ctime=%Z size=%s %N'\"'\"' hello.txt 2> /dev/null || stat -f '\"'\"'user=%Su group=%Sg mode=%Sp atime=%a mtime=%m ctime=%c size=%z %N%SY'\"'\"' hello.txt || ls -ld hello.txt )'\n    [pyinfra.api.facts] [@local] Loaded fact files.File (path=hello.txt)\n[pyinfra.connectors.local] --> Running command on localhost: sh -c 'rm -f hello.txt'\n    [@local] Success\n...\n```\n\n## Inventory + nginx setup [#](#inventory-nginx-setup)\n\nSo far we've been using my local machine as target host,\nbut we want beta to be as similar to prod as possible,\nso I've set up an Ubuntu virtual machine [with Vagrant](https://github.com/lemon24/pyinfra-demo/blob/main/infra/Vagrantfile).\n\nAlso, you will typically use\nan **inventory** file containing **groups** of hosts\ninstead of passing individual hosts directly to the CLI.\nFor our demo, we need just one group:\n\n```\n# group_name = [host1, host2, ...]\n\nmy_hosts = ['pyinfra.local']\n\n# you can have more groups:\n#\n# db_servers = ['db-1.net', 'db-2.net']\n# web_servers = [i.hostname for i in aws.ec2.list_instances(asg='my-asg')]\n```\n\nSince the inventory is a Python file, groups don't have to be list literals – you could, for example, import a library and list instances in an autoscaling group dynamically.\n\nHere's how you use an inventory to run arbitrary commands:\n\n``` php\n$ pyinfra inventories/beta.py exec -- lsb_release -d\n...\n--> Beginning operation run...\n--> Starting operation: server.shell (lsb_release -d)\n[pyinfra.local] Description: Ubuntu 26.04.1 LTS\n    [pyinfra.local] Success\n...\n```\n\nWe'll use nginx for both of our websites, which means we need to install it first.\n\nHere's what the main deploy looks like (minus imports):\n\n| infra/deploy.py |  | \n|---|---|\n| \n\n```\n 5\n 6\n 7\n 8\n 9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n```\n\n | \n\n```\napt.update(name=\"apt update\", cache_time=24 * 3600)\n\napt.packages('nginx', name=\"install nginx\")\n\ndisable_default = files.link(\n    name=\"disable default site\",\n    path='/etc/nginx/sites-enabled/default',\n    present=False,\n)\nserver.service(\n    name=\"reload nginx\",\n    service='nginx',\n    reloaded=True,\n    _if=disable_default.did_change,\n)\n```\n\n | \n\nWe update the package cache,\nwe install nginx,\nwe disable the default \"Welcome to nginx!\" site,\nand then we reload the nginx config.\nTo avoid unnecessary reloads,\nwe use the `_if` [global argument](https://docs.pyinfra.com/en/3.x/arguments.html)\nto reload only if the previous operation [actually ran](https://docs.pyinfra.com/en/3.x/deploy-process.html#checking-operation-changes).\n\nIn theory, we could use a fact to gate the last two operations instead:\n\n```\napt.packages(...)\nif host.get_fact(Link, '/etc/nginx/sites-enabled/default'):\n    files.link(...)\n    server.service(...)\n```\n\n...but that `if` happens in the *prepare* phase,\nwhen nginx (and the symlink) hasn't been installed yet,\nso they would be skipped on the first run.\n\nIf we run the deploy, no change is detected for \"disable default site\" (no symlink yet):\n\n``` php\n$ pyinfra inventories/beta.py deploy.py\n...\n--> Detected changes:\n    Operation              Change              Conditional Change\n    apt update             1 (pyinfra.local)   -\n    install nginx          1 (pyinfra.local)   -\n    disable default site   -                   -\n    reload nginx           -                   1 (pyinfra.local)\n\n    Detected changes may not include every change pyinfra will execute.\n    Hidden side effects of operations may alter behaviour of future operations,\n    this will be shown in the results. The remote state will always be updated\n    to reflect the state defined by the input operations.\n\n    Press enter to execute... [Enter]\n...\n--> Results:\n    Operation              Hosts   Success   Error   No Change\n    apt update             1       1         -       -\n    install nginx          1       1         -       -\n    disable default site   1       1         -       -\n    reload nginx           1       1         -       -\n...\n```\n\n...but the operation does run, as the prophecy foretold (\"side effects of operations may alter behaviour of future operations\").\n\n## Static site + code reuse [#](#static-site-code-reuse)\n\nWith nginx installed, we can proceed to the static home page.\n\nWe'll put the operations in a separate file that we include in the main deploy, which in addition to keeping code tidy, allows specific tasks to be conditional:\n\n| infra/deploy.py |  | \n|---|---|\n| \n\n```\n23\n24\n```\n\n | \n\n```\nif host.data.get('home_domain'):\n    local.include('tasks/home.py')\n```\n\n | \n\n**[Host data](https://docs.pyinfra.com/en/3.x/inventory-data.html#host-data)** is arbitrary data associated with hosts\nin the inventory file:\n\n```\n# group_name = [(host1, {'some custom': 'host data'}), ...]\n\nmy_hosts = [\n    ('pyinfra.local', {\n        'home_domain': 'pyinfra.local',\n    }),\n]  # fmt: skip\n```\n\nThe home page deploy itself is pretty straightforward:\n\n| infra/tasks/home.py |  | \n|---|---|\n| \n\n```\n 4\n 5\n 6\n 7\n 8\n 9\n10\n11\n12\n13\n```\n\n | \n\n``` python\nfrom modules import nginx\n\nsync_files = files.sync(\n    name=\"sync files\",\n    src='../home',\n    dest='/opt/home',\n    delete=True,\n)\n\nnginx.site('templates/nginx/home.conf', host.data.home_domain)\n```\n\n | \n\nThere's one operation\nthat [synchronizes files](https://docs.pyinfra.com/en/3.x/operations/files.html#files-sync) from a local directory to a remote one,\nand another operation that sets up an nginx site\nusing the domain from host data.\n\nExcept `nginx.site` isn't actually an operation,\nbut a [packaged deploy](https://docs.pyinfra.com/en/3.x/api/deploys.html);\nbesides the function definition that makes it reusable,\nit looks like any other deploy:\n\n| infra/modules/nginx.py |  | \n|---|---|\n| \n\n```\n 7\n 8\n 9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24\n25\n26\n27\n28\n```\n\n | \n\n``` python\n@deploy(\"nginx site\")\ndef site(template, domain, **context):\n\n    write_site = files.template(\n        name=\"write\",\n        src=template,\n        dest=f'/etc/nginx/sites-available/{domain}',\n        domain=domain,\n        **context,\n    )\n    enable_site = files.link(\n        name=\"enable\",\n        path=f'/etc/nginx/sites-enabled/{domain}',\n        target=f'/etc/nginx/sites-available/{domain}',\n    )\n\n    server.service(\n        name=\"reload\",\n        service='nginx',\n        reloaded=True,\n        _if=any_changed(write_site, enable_site),\n    )\n```\n\n | \n\n[files.template](https://docs.pyinfra.com/en/3.x/operations/files.html#files-template) uses [Jinja](https://jinja.palletsprojects.com/en/stable/),\nso we can have a base template for all HTTP sites:\n\n```\nserver {\n    listen 80;\n    listen [::]:80;\n    server_name {{ domain }};\n\n    {% block server %}{% endblock %}\n}\n```\n\n...that we then extend with site-specific configuration:\n\n```\n{% extends 'templates/nginx/http.conf' %}\n\n{% block server -%}\n\n    location / {\n        root /opt/home;\n    }\n\n{%- endblock %}\n```\n\nPassing `--diff` shows the rendered template, which is pretty neat:\n\n``` php\n$ pyinfra inventories/beta.py deploy.py --diff\n...\n--> Preparing operation files...\n    Loading: deploy.py\n    [pyinfra.local] Will create /etc/nginx/sites-available/pyinfra.local\n      @@ -0,0 +1,9 @@\n      + server {\n      +     listen 80;\n      +     listen [::]:80;\n      +     server_name pyinfra.local;\n      +\n      +     location / {\n      +         root /opt/home;\n      +     }\n      + }\n\n    [pyinfra.local] Ready: deploy.py\n\n--> Detected changes:\n    Operation                             Change              Conditional Change\n    apt update                            -                   -\n    install nginx                         -                   -\n    disable default site                  -                   -\n    reload nginx                          -                   1 (pyinfra.local)\n    tasks/home.py | sync files            1 (pyinfra.local)   -\n    tasks/home.py | nginx site | write    1 (pyinfra.local)   -\n    tasks/home.py | nginx site | enable   1 (pyinfra.local)   -\n    tasks/home.py | nginx site | reload   -                   1 (pyinfra.local)\n...\n```\n\nAnd now we have a static site!\n\n## Web app + secrets [#](#web-app-secrets)\n\nThe web application is a simple Flask app that we can install with pip:\n\n``` bash\n$ tree app\napp\n├── app\n│   ├── __init__.py\n│   ├── static\n│   │   └── style.css\n│   └── templates\n│       └── index.html\n└── pyproject.toml\n```\n\nWe serve it using a traditional\n[Gunicorn being nginx on a Unix socket](https://gunicorn.org/deploy/#nginx-configuration) setup:\n\n```\n   :80, :443\n       |\n.-------------.\n|    nginx    |  groups: app-socket, www-data\n'-------------'\n       |\n    app.sock     groups: app-socket\n       |\n.- Gunicorn --.\n|   web app   |  groups: app-socket, app\n'-------------'\n```\n\nThe deploy for it is slightly bigger, but hopefully still straightforward.\n\nWe have operations to create users and groups:\n\n| infra/tasks/app.py |  | \n|---|---|\n| \n\n```\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24\n25\n26\n27\n28\n```\n\n | \n\n```\nserver.group('app', name=\"create app group\")\nserver.group('app-socket', name=\"create socket group\")\nserver.user(\n    name=\"create user\",\n    user='app',\n    group='app',\n    groups=['app-socket'],\n    home=\"/var/lib/app\",\n    shell=\"/usr/sbin/nologin\",\n    system=True,\n)\nserver.user(\n    name=\"nginx socket access\",\n    user='www-data',\n    groups=['app-socket'],\n    append=True,\n)\n```\n\n | \n\n...to create a virtual environment with our app and its dependencies:\n\n| infra/tasks/app.py |  | \n|---|---|\n| \n\n```\n30\n31\n32\n33\n34\n35\n36\n37\n38\n39\n40\n41\n42\n43\n44\n45\n```\n\n | \n\n```\napt.packages('python3-venv', name=\"install venv\")\npip.venv('/opt/app', 'python3', name=\"create venv\")\npip.packages(['gunicorn'], virtualenv='/opt/app', name=\"install deps\")\n\nsync_files = files.sync(\n    name=\"sync app files\",\n    src='../app',\n    dest='/opt/app/src',\n    delete=True,\n    exclude_dir=['**/__pycache__'],\n)\ninstall_app = server.shell(\n    \"/opt/app/bin/pip install /opt/app/src\",\n    name=\"install app\",\n    _if=sync_files.did_change,\n)\n```\n\n | \n\n...to create and start a systemd service for Gunicorn:\n\n| infra/tasks/app.py |  | \n|---|---|\n| \n\n```\n56\n57\n58\n59\n60\n61\n62\n63\n64\n65\n66\n67\n68\n```\n\n | \n\n```\nwrite_service = files.template(\n    name=\"write service\",\n    src='templates/systemd/app.service',\n    dest='/etc/systemd/system/app.service',\n)\nsystemd.service(\n    name=\"enable service\",\n    service='app.service',\n    enabled=True,\n    restarted=True,\n    daemon_reload=True,\n    _if=any_changed(install_app, write_config, write_service),\n)\n```\n\n | \n\n...and finally to configure nginx:\n\n| infra/tasks/app.py |  | \n|---|---|\n| \n\n```\n70\n```\n\n | \n\n```\nnginx.site('templates/nginx/app.conf', host.data.app_domain)\n```\n\n | \n\nIn addition, Flask needs a [secret key](https://flask.palletsprojects.com/en/stable/config/#SECRET_KEY), which usually comes from a config file:\n\n| infra/tasks/app.py |  | \n|---|---|\n| \n\n```\n47\n48\n49\n50\n51\n52\n53\n54\n```\n\n | \n\n```\nwrite_config = files.template(\n    name=\"write config\",\n    src='templates/config.py.jinja',\n    dest='/etc/opt/app/config.py',\n    group='app',\n    mode=640,\n    get_secret=secrets.get_secret,\n)\n```\n\n | \n\nThe question is, where do we store the secret key before writing the config?\n\nThe simple (and wrong) solution is to hardcode it in the deploy or in the template; unencrypted-secrets-in-version-control issue aside, we'd also like the secrets to be different between beta and prod.\n\nAs I said,\na deploy is a Python file,\nso we can do anything:\nwe can get them from a\n[password](https://keyring.readthedocs.io/en/latest/)\n[manager](https://keyring.readthedocs.io/en/latest/#third-party-backends),\na security key,\nor even an [encrypted](https://pypi.org/project/keyrings.cryptfile/)\n[file](https://pypi.org/project/privy/).\nTo illustrate the point,\nhere's a simple function\nthat reads named secrets from an unencrypted JSON file:[4](#fn-4)\n\n``` python\nimport json\nfrom pyinfra import host\n\nsecrets_path = 'secrets.json'\n\ndef get_secret(name, *, stage=None):\n    if not stage:\n        stage = host.data.stage\n\n    with open(secrets_path) as f:\n        data = json.load(f)\n\n    if secret := data.get(stage, {}).get(name):\n        return secret\n\n    raise RuntimeError(f\"secret {name!r} for stage {stage!r} not found\")\n```\n\nThe stage comes from host data,\nthis time not in the inventory,\nbut in a [group data file](https://docs.pyinfra.com/en/3.x/inventory-data.html#group-data-files)\nthat applies to all hosts in a group\n(`beta`, the inventory name, is an implicit group):\n\n```\nstage = 'beta'\n```\n\nThe config template looks like this:\n\n```\nSECRET_KEY = {{ '{!r}'.format(get_secret('app_secret_key')) }}\n```\n\nDeploying looks the same, only with way more operations:\n\n``` php\n$ pyinfra inventories/beta.py deploy.py\n...\n--> Results:\n    Operation                             Hosts   Success   Error   No Change\n    ...\n    Grand total                           23      15        -       8\n...\n```\n\nAnd now we have a web app:\n\n## HTTPS + prod [#](#https-prod)\n\nThat's a nice looking web app, but it would be even nicer if it were not \"not secure\". This requires HTTPS, which requires a TLS certificate.\n\nFortunately,\nthose are easy to get these days –\n[Let's Encrypt](https://en.wikipedia.org/wiki/Let%27s_Encrypt) provides free certificates,\nand [Certbot](https://certbot.eff.org/pages/about) will request, install, and renew them automatically.\n\nUnfortunately, there's no built-in Certbot operation, so we have to write a custom one:\n\n| infra/modules/certbot.py |  | \n|---|---|\n| \n\n```\n 8\n 9\n10\n11\n12\n13\n14\n```\n\n | \n\n``` python\n@operation()\ndef certificate(domain):\n\n    if domain in host.get_fact(Certificates):\n        return\n\n    yield f\"certbot certonly -d {quote(domain)} -n --nginx --agree-tos\"\n```\n\n | \n\nLike in the [initial example](#where-are-the-commands),\nwe read the host state using a fact\nand then return commands if it doesn't match the desired state.\nThe fact is custom too:\n\n| infra/modules/certbot.py |  | \n|---|---|\n| \n\n```\n17\n18\n19\n20\n21\n22\n23\n24\n25\n```\n\n | \n\n```\nclass Certificates(FactBase):\n    default = dict\n    requires_command = \"certbot\"\n\n    def command(self):\n        return \"certbot certificates\"\n\n    def process(self, output):\n        return {c['name']: c for c in _parse_certbot_certificates(output)}\n```\n\n | \n\nAnd we already have a convenient place where we can use the operation:\n\n| infra/modules/nginx.py |  | \n|---|---|\n| \n\n```\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19\n20\n21\n22\n23\n24\n25\n26\n```\n\n | \n\n``` python\n@deploy(\"nginx site\")\ndef site(template, domain, https=None, **context):\n    reload_ops = []\n\n    if https is None:\n        https = host.data.get('nginx_https')\n    if https:\n        reload_ops.append(certbot.certificate(domain, name=\"cert\"))\n\n    write_site = files.template(\n        name=\"write\",\n        src=template,\n        dest=f'/etc/nginx/sites-available/{domain}',\n        base=f\"templates/nginx/http{'s' if https else ''}.conf\",\n        domain=domain,\n        **context,\n    )\n```\n\n | \n\nAlso unfortunately, Certbot needs a resolvable domain name to work, so we can't test this using the beta virtual machine... but we can always test in prod. ( ≖‿ ≖ )\n\nHere's a new inventory with two hosts, each with its own domain and HTTPS enabled:\n\n```\nmy_hosts = [\n    ('pyinfra.andgravity.com', {\n        'home_domain': 'pyinfra.andgravity.com',\n    }),\n    ('app.pyinfra.andgravity.com', {\n        'app_domain': 'app.pyinfra.andgravity.com',\n    }),\n]  # fmt: skip\nstage = 'prod'\nnginx_https = True\n```\n\nI say we just go for it:\n\n``` bash\n$ pyinfra inventories/prod.py deploy.py --yes\n... stuff happens ...\n```\n\nOne minute later, there are ✨ two new websites ✨ on the internet:[5](#fn-5)\n\n## Questions? [#](#questions)\n\nComing soon :)\n\nAnyway, that's it for now.\n\n**Learned something new today?** Share it with others, it really helps! \n\n1. And you're bound to have extra dev and pre-prod machines anyway. [<sup>\\[return\\]</sup>](#fnref-1)\n2. Say you have a memory leak during a high traffic period and need to reload stuff once in a while until you can find the root cause. [<sup>\\[return\\]</sup>](#fnref-2)\n3. Deploy deploy code runs once per host, so you *could* have different branches on different hosts\nwithin the same run.[<sup>\\[return\\]</sup>](#fnref-3)\n4. Which you absolutely *should not* do,\nuse[keyrings.cryptfile](https://pypi.org/project/keyrings.cryptfile/) instead.[<sup>\\[return\\]</sup>](#fnref-4)\n5. Well, not anymore, there's no point in paying to host this :) [<sup>\\[return\\]</sup>](#fnref-5)", "url": "https://wpnews.pro/news/pyinfra-simple-infrastructure-automation-in-python", "canonical_source": "https://death.andgravity.com/pyinfra", "published_at": "2026-11-03 19:58:26+00:00", "updated_at": "2026-11-03 20:20:38.074339+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["pyinfra", "Adrian", "Ansible", "Mitogen", "Python", "reader"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/pyinfra-simple-infrastructure-automation-in-python", "markdown": "https://wpnews.pro/news/pyinfra-simple-infrastructure-automation-in-python.md", "text": "https://wpnews.pro/news/pyinfra-simple-infrastructure-automation-in-python.txt", "jsonld": "https://wpnews.pro/news/pyinfra-simple-infrastructure-automation-in-python.jsonld"}}