Skip to content
Stuff I'm Up To
Go back
Running Python 3.12 on Ubuntu 18.04 without touching apt

Running Python 3.12 on Ubuntu 18.04 without touching apt

We had a fleet of small field devices still on Ubuntu 18.04, stuck there for reasons that had nothing to do with us. The system Python was 3.6.9, which reached end of life in 2021. We needed to deploy a business application that requires Python ≥ 3.10.

Upgrading the OS wasn’t an option. This post covers how we got a current Python onto those devices anyway: no apt, no compiler, no internet access from the device, and no changes to the system Python.

Why the usual fixes didn’t work

The idea: build everything on the controller, copy it over

Ansible already has an SSH connection to every device, so everything the device needs goes down that connection:

controller (staging dir)                                     device
───────────────────────────────────────────────────         ─────────────────────────────────
python.tar.gz   standalone CPython 3.12 (sha256 pinned) ──▶  /opt/myapp-python/   runtime
wheels/         manylinux2014 wheels, pinned versions   ──▶  venv: pip install --no-index
app.tar.gz      git archive <ref>                       ──▶  /opt/myapp/          app + venv

There are three parts.

1. A relocatable Python runtime

python-build-standalone publishes self-contained CPython builds. They’re the same builds uv downloads when you ask it for a Python. The install_only tarballs:

uv will tell you the exact download URL, and each release publishes a SHA256SUMS file:

uv python list --show-urls --only-downloads | grep 'cpython-3.12.*-linux-x86_64-gnu '

2. An offline wheelhouse, built for the target, not the controller

pip download can fetch wheels for a platform and Python version other than the one it’s running on. Asking for manylinux2014 is the same as asking for glibc 2.17 compatibility, which matches the runtime:

pip download --only-binary=:all: \
  --platform manylinux2014_x86_64 --python-version 3.12 --implementation cp \
  -d wheels -r requirements.txt

--only-binary=:all: matters. It guarantees nothing has to be compiled on the device, and the download fails loudly on the controller if a package has no suitable wheel. Keep the requirement list to what the app actually imports on these devices. Ours dropped every heavyweight cloud and database client the app only needs elsewhere.

On the device, the bundled Python creates the venv (it ships with ensurepip), and pip installs only from the pushed wheels:

/opt/myapp-python/bin/python3 -m venv /opt/myapp
/opt/myapp/bin/pip install --no-index --find-links /opt/myapp-wheels -r /opt/myapp-wheels/requirements.txt

3. The app itself

We run git archive on a pinned ref from a checkout on the controller. There’s no deploy key on the device and no GitHub access needed.

The Ansible side

Here it is, trimmed down. Staging runs once per play on the controller:

- name: Stage the bundle on the controller
  delegate_to: localhost
  run_once: true
  become: false
  check_mode: false # only writes to a local staging dir; keeps --check useful
  block:
    - name: Download the standalone Python build
      ansible.builtin.get_url:
        url: "{{ myapp_python_url }}"
        dest: "{{ stage }}/python.tar.gz"
        checksum: "sha256:{{ myapp_python_sha256 }}"
        mode: "0600"

    - name: Download the offline wheelhouse
      ansible.builtin.command:
        cmd: >-
          uvx pip download --only-binary=:all: --platform manylinux2014_x86_64
          --python-version 3.12 --implementation cp
          -d {{ stage }}/wheels -r {{ stage }}/requirements.txt
      changed_when: false

    - name: Resolve the app revision
      ansible.builtin.command: git -C {{ myapp_src }} rev-parse --verify {{ myapp_ref }}^{commit}
      register: myapp_rev
      changed_when: false

    - name: Archive the app
      ansible.builtin.command: git -C {{ myapp_src }} archive --format=tar.gz -o {{ stage }}/app.tar.gz {{ myapp_rev.stdout }}
      changed_when: false

On the device, a marker file records exactly what’s installed: the Python checksum, the app revision and a hash of the pins. The bundle is only pushed again when one of those changes, so re-runs are quick and send nothing over a slow link:

- name: Work out the bundle version
  ansible.builtin.set_fact:
    bundle_version: >-
      {{ myapp_python_sha256 }} {{ myapp_rev.stdout }}
      {{ myapp_requirements | join(',') | hash('sha256') }}

- name: Read the installed bundle version
  ansible.builtin.slurp:
    src: /opt/myapp/.bundle
  register: installed
  failed_when: false

- name: Decide whether to (re)install
  ansible.builtin.set_fact:
    needs_install: "{{ (installed.content | default('') | b64decode | trim) != (bundle_version | trim) }}"

# ...then, each with `when: needs_install | bool`:
#   unarchive python.tar.gz  -> /opt/myapp-python  (extra_opts: --strip-components=1)
#   unarchive app.tar.gz     -> /opt/myapp
#   copy wheels/             -> /opt/myapp-wheels/
#   python3 -m venv, pip install --no-index, remove the wheelhouse
#   write /opt/myapp/.bundle  (last, so a failed install is retried next run)

After that it’s an ordinary app: an .env, a systemd service and timer, and systemctl enable --now. The system python3 is never touched, so whatever else on the box depends on it keeps working.

Proving it before touching a real device

We tested in three steps, each closer to a real device:

TargetWhat it proved
ubuntu:18.04 container with --network noneThe runtime runs on glibc 2.27, the venv installs fully offline, and the app runs
Ubuntu 18.04 container with systemd and Python 3.6The Ansible role works end to end, the second run reports changed=0, and the timer fires
generic/ubuntu1804 Vagrant VM, password SSH and sudoThe full playbook runs like a real device, repeat runs change nothing, and there are no tracebacks

Gotchas we hit

Why we like it

It’s a bit mad that the easiest way to get Python 3.12 onto an Ubuntu 18.04 box was to not ask Ubuntu at all. But it works.

For the story of how this plan came together — including testing it first in Docker, then in a proper Vagrant/libvirt VM before it ever touched a real device — see The Day Claude Code Got Creative With Legacy Python.


Share this post:

Previous Post
Naming Bluetooth Devices with bluetoothctl
Next Post
Git and apt via SSH Reverse Tunnel