P3
Developer Tools Azure

Python 3.12 on Ubuntu 24.04 LTS on Azure User Guide

| Product: Python 3.12 on Ubuntu 24.04 LTS on Azure

Overview

This guide covers the deployment and use of Python 3.12 on Ubuntu 24.04 LTS on Azure from the cloudimg Azure Marketplace images.

This image is a development workstation, not a server. You connect to it over SSH and use it to write, build, test and package Python code. It runs no network service of any kind: after first boot the only listening socket reachable from outside the machine is SSH on port 22. There is no web console, no dashboard and no login page, and consequently no credential of any kind ships in the image — there is nothing to log in to and nothing to rotate.

Ubuntu already includes a Python interpreter, so the value of this image is everything built around it. In particular it answers the question that costs a Python developer the most time on a fresh machine: what happens when a package has no prebuilt wheel for this platform? On a stock cloud image that ends in a compiler error. Here it compiles, because the C, C++ and Fortran toolchains and the development headers that source built packages look for are already installed and proven at build time.

The interpreter is deliberately Ubuntu's own. CPython 3.12.3 comes from noble's main component (package python3.12, version 3.12.3-1ubuntu0.15), which is carried in both noble-updates and noble-security. That means Canonical publishes security fixes for this exact interpreter across the LTS support window. A newer 3.13 from a third party archive would sit entirely outside that coverage, and third party archives have also been observed to pin their dependencies to exact versions in ways that silently block future security updates. The build refuses to continue if python3 -V is not exactly Python 3.12.3, or if any non Ubuntu package repository has been configured.

Where your work lives. A dedicated 32 GiB data volume is attached at /srv/pydata and carries the pip download and wheel cache, the pipx home, a directory for project virtual environments and a workspace. The cache location is set in /etc/pip.conf, which is pip's global configuration file, so it applies everywhere — an interactive shell, a non interactive ssh host 'pip ...', a systemd unit, a cron job, and inside every virtual environment you create. You can grow or snapshot that volume independently of the operating system disk.

What is included

  • CPython 3.12.3 from Ubuntu main, with python3.12-dev, python3.12-venv and python3-pip, all at 3.12.3-1ubuntu0.15

  • A complete native build toolchain: gcc, g++, gfortran, make, cmake, ninja and pkg-config, plus the development headers most often needed by source built wheels — libffi, OpenSSL, zlib, bzip2, lzma, SQLite, readline, libxml2, libxslt, libjpeg, libpng, FreeType, libyaml, libcurl, libpq (PostgreSQL) and OpenBLAS

  • ruff 0.16.0, an extremely fast linter and formatter, and black 26.5.1

  • python-lsp-server 1.14.0 (pylsp), so Neovim, Helix, Zed, Emacs or any other LSP client gives you completion, hover and navigation the moment you connect over SSH

  • IPython 9.15.0 for an interactive shell, pipx 1.16.3 for installing standalone command line applications, virtualenv 21.7.0, and build 1.5.0 (pyproject-build) for producing wheels and source distributions

  • A dedicated 32 GiB data volume at /srv/pydata holding the pip cache, the pipx home, venvs/ and workspace/, already warm with the wheels the guide uses

  • cloudimg-python-selftest, which compiles a real C extension from source, runs it, checks the number it computes, runs a test suite against it and then compiles a third party package from its source distribution

  • cloudimg-python-licence-audit, a fail closed audit of every installed distribution's licence, and the resulting table shipped on the image

Prerequisites

  • An Azure subscription with permission to create virtual machines
  • An SSH key pair (ssh-keygen -t ed25519 if you do not already have one)
  • A network security group that allows inbound TCP 22 from your own address only

The recommended size is Standard_B2s (2 vCPU, 4 GiB). Compiling large scientific packages from source benefits from more cores; Standard_D4s_v5 or larger is a reasonable choice for that work.

Step 1: Deploy from the Azure Portal

  1. In the Azure Portal choose Create a resource and search the Marketplace for Python 3.12 on Ubuntu 24.04 LTS by cloudimg.
  2. Select the offer, choose the plan, and click Create.
  3. On Basics, pick your subscription, resource group and region, set the VM size to Standard_B2s, choose SSH public key authentication and paste your public key. The username you enter here becomes the developer account.
  4. On Disks, leave the attached 32 GiB data disk in place — it is where the caches and your workspace live.
  5. On Networking, restrict inbound SSH (22) to your own IP address. Do not open any other port; this image serves nothing else.
  6. Review and create.

Step 2: Deploy from the Azure CLI

az vm create \
  --resource-group my-python-rg \
  --name my-python-vm \
  --image cloudimg:python:default:latest \
  --size Standard_B2s \
  --storage-sku StandardSSD_LRS \
  --admin-username azureuser \
  --ssh-key-values ~/.ssh/id_ed25519.pub \
  --public-ip-sku Standard

# then restrict inbound SSH to your own address
az vm open-port --resource-group my-python-rg --name my-python-vm --port 22 --priority 1001

Both commands run on your own machine, not on the VM.

Step 3: First boot

A single one shot systemd unit, python-firstboot.service, runs once on the first boot. The only thing that genuinely cannot be decided when the image is built is who you are — the admin account name is chosen at VM creation time — so first boot resolves that account, grants it access to the data volume, records the resolved environment and drops a sentinel file. It rotates no credentials, because this image has none.

Check that it completed:

systemctl is-active python-firstboot.service
test -f /var/lib/cloudimg/python-firstboot.done && echo "firstboot sentinel present"

Expected output:

active
firstboot sentinel present

The environment it recorded is a plain, non secret manifest:

cat /var/lib/cloudimg/python-workspace.env
# cloudimg Python workstation — resolved environment for this VM.
# Written once by python-firstboot.service. This file contains NO secrets: this image runs no
# network service, so there is nothing to authenticate against and nothing to rotate.
PYTHON_VERSION=3.12.3
PYTHON_BIN=/usr/bin/python3
RUFF_VERSION=0.16.0
PIP_CACHE_DIR=/srv/pydata/cache/pip
PIPX_HOME=/srv/pydata/pipx
PIPX_BIN_DIR=/srv/pydata/pipx/bin
PYVENVS=/srv/pydata/venvs
PYWORKSPACE=/srv/pydata/workspace
CLOUDIMG_TOOLVENV=/opt/cloudimg/pytools
PYDEV_USER=azureuser
PYDEV_GROUP=pydev
PYDATA_MOUNTED=yes
FIRSTBOOT_COMPLETED=2026-07-27T00:45:59Z

Step 4: Connect and confirm the interpreter

ssh azureuser@<vm-ip>

The login banner summarises the machine. Confirm the interpreter and the packages that provide it:

python3 -V
dpkg-query -W python3.12 python3.12-minimal python3.12-dev python3.12-venv

Expected output:

Python 3.12.3
python3.12  3.12.3-1ubuntu0.15
python3.12-dev  3.12.3-1ubuntu0.15
python3.12-minimal  3.12.3-1ubuntu0.15
python3.12-venv 3.12.3-1ubuntu0.15

All four come from Ubuntu main, which is what keeps them inside Canonical's security coverage. Confirm the pip cache resolves onto the data volume even with a completely empty environment — this is the check that proves it is set globally rather than in a login profile:

cat /etc/pip.conf
env -i /usr/bin/python3 -m pip cache dir

Expected output:

# cloudimg Python workstation — global pip configuration.
# The download/wheel cache lives on the dedicated data volume so it survives, is large, and
# is shared by every virtual environment on this VM.
[global]
cache-dir = /srv/pydata/cache/pip
disable-pip-version-check = true
/srv/pydata/cache/pip

And the tooling:

ruff --version
black --version
virtualenv --version
pipx --version
gcc --version | sed -n '1p'

Expected output:

ruff 0.16.0
black, 26.5.1 (compiled: yes)
Python (CPython) 3.12.3
virtualenv 21.7.0 from /opt/cloudimg/pytools/lib/python3.12/site-packages/virtualenv/__init__.py
1.16.3
gcc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0

Terminal showing python3 reporting Python 3.12.3, all four Ubuntu python3.12 packages at version 3.12.3-1ubuntu0.15, the global pip configuration pointing the cache at the dedicated /srv/pydata volume, the same cache path resolving with an empty environment, the first boot environment manifest, and the installed versions of ruff, black, virtualenv, pipx and gcc

Step 5: Verify the whole workstation in one command

cloudimg-python-selftest is the fastest way to satisfy yourself the machine works. It does not print version banners and call that a test. It creates a real virtual environment, has pip compile a C extension from source, imports the result and checks the number it computes, installs pytest with your own pip and runs a suite that cross checks the C implementation against an independent pure Python one, then compiles psutil from its source distribution to prove the toolchain also works on code cloudimg did not write.

cloudimg-python-selftest

It takes about a minute on a Standard_B2s. Every line begins ok, and it ends:

cloudimg-python-selftest: ALL CHECKS PASSED

Terminal showing cloudimg-python-selftest completing every stage: the pinned Python 3.12.3 interpreter from the Ubuntu archive, the PEP 668 guard rail intact, every optional standard library extension module importing, a virtual environment created, the pip cache resolving to the data volume, a native C extension built from source and confirmed by file as an x86-64 ELF shared object, the extension computing PRIMES=17984 SUM=1709600813, twelve pytest tests passing, psutil compiled from its source distribution and returning real host metrics, and the cache entries stored on the data volume

Two switches are available: --offline skips everything that needs the package index, and --full is reserved for the longer build set used at image build time.

Step 6: Where your code and caches live

findmnt /srv/pydata
df -h /srv/pydata
ls -l /srv/pydata

Expected output:

TARGET     SOURCE    FSTYPE OPTIONS
/srv/pydata /dev/sdc ext4   rw,relatime
Filesystem      Size  Used Avail Use% Mounted on
/dev/sdc         31G  1.2G   29G   4% /srv/pydata
total 24
drwxrwsr-x 3 root pydev 4096 Jul 27 00:38 cache
drwxrwsr-x 3 root pydev 4096 Jul 27 00:38 pipx
drwxrwsr-x 2 root pydev 4096 Jul 27 00:38 venvs
drwxrwsr-x 2 root pydev 4096 Jul 27 00:38 workspace

The directories are group owned by pydev with the set group id bit and a POSIX default ACL, and first boot added your account to that group and granted it a direct ACL, so you can write to all of them without sudo. The ACL is applied to the top level directories only; everything beneath already inherits it, which is why first boot completes instantly rather than walking a large cache tree.

The fstab entry is keyed by filesystem UUID with nofail, so the volume comes back on every reboot and a missing disk never blocks boot. The disk is addressed by its Azure LUN path rather than a device letter, because device letters move between reboots.

pipx is configured to install standalone applications onto the same volume:

pipx environment --value PIPX_HOME
pipx environment --value PIPX_BIN_DIR

Expected output:

/srv/pydata/pipx
/srv/pydata/pipx/bin

Installing a command line application with pipx install <name> puts it in its own isolated environment and links it into /srv/pydata/pipx/bin, which is on your PATH. That is the right way to install a tool; the next step covers installing libraries.

Step 7: Start a project

The system interpreter is protected. Ubuntu marks it externally managed under PEP 668 so that installing packages system wide cannot break the operating system, and that guard rail is deliberately left intact on this image. It is not an obstacle, it is the correct behaviour, and the workflow it pushes you towards is one virtual environment per project:

python3 -m venv /srv/pydata/venvs/myproject
source /srv/pydata/venvs/myproject/bin/activate
python -V

Inside the environment, pip refers to that environment's pip and installs only into it:

pip install requests

Everything pip downloads is cached on the data volume, so the second project that needs the same package installs from local disk. If you prefer a virtual environment that starts with a newer pip and setuptools than the ones Ubuntu bundles, use virtualenv /srv/pydata/venvs/myproject instead — it seeds its own.

Leave the environment with deactivate.

Step 8: Compile a package that has no wheel

This is what the image exists for. Forcing --no-binary makes pip refuse the prebuilt wheel and build from the source distribution instead, which needs a C compiler and the CPython development headers:

Run this inside the activated environment from Step 7, then confirm the extension really was compiled here and that it works:

pip install --no-binary :all: psutil==7.2.2
python -c "import psutil; print(psutil._psutil_linux.__file__)"
python -c "import psutil; print('cpus', psutil.cpu_count(), 'ram_mib', psutil.virtual_memory().total // 1048576)"

Expected output on a Standard_B2s:

/srv/pydata/venvs/myproject/lib/python3.12/site-packages/psutil/_psutil_linux.abi3.so
cpus 2 ram_mib 3912

Terminal showing a project virtual environment created on the data volume, pip installing psutil 7.2.2 with no-binary so it compiles from the source distribution, the resulting extension confirmed by file as a 64-bit ELF shared object, psutil importing and reporting two CPUs and 3912 MiB of RAM, and the pip download cache sitting on the /srv/pydata volume

The same applies to anything else that ships only an sdist for your platform, or that you deliberately want built locally: lxml finds libxml2 and libxslt, psycopg finds libpq, Pillow finds libjpeg, libpng and FreeType, and numerically heavy packages find OpenBLAS and gfortran.

Step 9: Lint, format and test

ruff is on the PATH and needs no configuration to be useful. It operates on source text, so running it globally is correct:

cd /srv/pydata/workspace
printf 'import os\n\n\ndef f( x ):\n    return x+1\n' > sample.py
ruff check sample.py
ruff format sample.py
cat sample.py

ruff check reports the unused os import, and ruff format rewrites the spacing. black is available as an alternative formatter.

For tests, install pytest into the project's environment rather than globally — a globally installed test runner executes with the wrong interpreter and cannot see your project's dependencies. That is why this image deliberately does not put pytest on the system PATH. Its wheel is already in the cache on the data volume, so the install is served locally:

pip install pytest
python -m pytest

Step 10: Remote development from your editor

The machine is ready for an editor attached over SSH.

  • VS Code: install the Remote SSH extension locally and connect to azureuser@<vm-ip>. The Python extension will find /usr/bin/python3 and any virtual environment under /srv/pydata/venvs/. Select the project's environment with Python: Select Interpreter.
  • Neovim, Helix, Zed, Emacs, Kate: pylsp is installed and on the PATH, so point your LSP client at the pylsp command. It provides completion, hover, signature help and go to definition through jedi.

Because the caches live on the data volume and are shared by every environment, an editor that rebuilds an environment does not re download anything.

Step 11: The licence audit

Every distribution installed on this image, direct and transitive, has been audited against its real licence artifact rather than a badge on a project page. The auditor resolves each licence in four tiers: PEP 639 License-Expression metadata, then trove classifiers, then the legacy License field, then the licence files the distribution ships inside its own .dist-info directory. For packages installed by apt it additionally reads the Debian machine readable copyright file. Anything it cannot resolve is a policy violation, not a warning — the audit fails closed.

The result ships on the image and you can re run it at any time:

cloudimg-python-licence-audit --acknowledged 'docstring-to-markdown pathspec' --quiet
grep -E '^AUDIT VERDICT' /usr/share/doc/cloudimg-python/licenses/python-dependency-licences.txt

Expected output:

AUDIT VERDICT: CLEAN — 125 distributions examined, no policy violation
AUDIT VERDICT: CLEAN — 125 distributions examined, no policy violation

Two distributions in cloudimg's tool environment are weak copyleft rather than permissive, and both are named explicitly on the acknowledged list above:

  • docstring-to-markdown (LGPL-2.1-or-later), a dependency of python-lsp-server
  • pathspec (MPL-2.0), a dependency of black

Both are pure Python and ship completely unmodified, so the corresponding source is exactly what is installed on the machine. The auditor rejects any other weak copyleft distribution, so that list cannot grow without the build failing. Strong copyleft and the source available licence family (SSPL, Business Source, Commons Clause, Elastic) are rejected outright.

The interpreter's own licence is the PSF License Agreement, a permissive, GPL compatible licence. The full text, together with the licences of the third party code CPython bundles, is on the image:

grep -c 'PYTHON SOFTWARE FOUNDATION LICENSE' /usr/share/doc/cloudimg-python/licenses/cpython/copyright
find /usr/share/doc/cloudimg-python/licenses -type f | wc -l

Step 12: Security posture

ss -Hltn

Expected output — SSH, plus the loopback only DNS stub resolver that every stock Ubuntu image runs:

LISTEN 0      4096       127.0.0.54:53   0.0.0.0:*
LISTEN 0      4096          0.0.0.0:22   0.0.0.0:*
LISTEN 0      4096   127.0.0.53%lo:53    0.0.0.0:*
LISTEN 0      4096            [::]:22    [::]:*

You can confirm that directly. The first probe is a positive control: it has to succeed, otherwise a refusal on the second probe would prove nothing at all, because a probe that never works refuses everything.

timeout 5 bash -c 'exec 3<>/dev/tcp/127.0.0.1/22' && echo "22 accepted (positive control)"
timeout 5 bash -c 'exec 3<>/dev/tcp/127.0.0.1/8000' || echo "8000 refused, as expected"
swapon --show || echo "no swap"

Expected output:

22 accepted (positive control)
8000 refused, as expected
no swap

Terminal showing the shipped dependency licence table reporting a clean verdict over 125 distributions, the two acknowledged weak copyleft components with their LGPL-2.1 and MPL-2.0 licences, the PSF License Agreement text confirmed present in the CPython copyright artifact, 120 licence files shipped in the image, ss confirming the only listening sockets are SSH on port 22 and the loopback DNS stub resolver, and swapon reporting no swap

Notes on the posture:

  • No credentials ship in the image. There is no service to authenticate to. /var/lib/cloudimg/python-workspace.env is a plain environment manifest, not a secrets file.
  • No swap is present, on the OS disk or anywhere else, as Azure image certification requires.
  • Unattended security upgrades are enabled, exactly as on a stock Ubuntu image.
  • The PEP 668 guard rail is intact. Installing into the system interpreter with --break-system-packages is possible but is not the supported workflow; use a virtual environment or pipx.
  • Keep the network security group restricted to SSH from your own address. If you expose an application you develop here, put it behind a separate, properly configured front end rather than opening a port on the workstation.

Resizing and backing up the data volume

The data volume is an ordinary Azure managed disk. To grow it, deallocate the VM, resize the disk, start the VM and grow the filesystem:

sudo resize2fs /dev/disk/azure/scsi1/lun0
df -h /srv/pydata

Snapshot it from the Azure CLI whenever you want a restore point for your workspace. Nothing in /srv/pydata is required for the machine to boot, so a snapshot restore is non disruptive.

Updating

Operating system packages, including the interpreter itself, come from the Ubuntu archive:

sudo apt-get update
apt list --upgradable

Apply updates with sudo apt-get dist-upgrade in your own maintenance window. Because the interpreter is an archive package, a CPython security fix reaches you through the same channel as the rest of the operating system.

To update the Python packages inside one of your own environments, activate it and use pip install --upgrade. cloudimg's tool environment at /opt/cloudimg/pytools is deliberately separate and is not on your PATH as a pip target, so your upgrades can never disturb it and its upgrades can never disturb you.

Troubleshooting

error: externally-managed-environment when installing a package. This is PEP 668 working as intended: you are outside a virtual environment. Create one with python3 -m venv /srv/pydata/venvs/<name> and activate it, or use pipx for a standalone application.

A package fails to build from source. Read the compiler error for the missing header it names, then install the matching -dev package with apt-get. The common ones are already present; anything unusual is one apt-get install lib<name>-dev away.

/srv/pydata is missing after a resize or a disk swap. Confirm the entry in /etc/fstab still matches the volume's UUID with sudo blkid, then sudo mount -a.

Permission denied writing to /srv/pydata. Confirm your account is in the pydev group with id. First boot adds the account that existed at that moment; an account created later needs sudo usermod -aG pydev <user> and a fresh login.

The first boot manifest is missing. Check the unit with systemctl status python-firstboot.service and journalctl -u python-firstboot.service. It is a one shot unit that runs only while its sentinel is absent.

Support

cloudimg provides 24/7 support for this image. Questions about the image, the toolchain layout or the data volume are in scope; questions about your own application code are not.

Python is a trademark of the Python Software Foundation. cloudimg is not affiliated with, sponsored by, or endorsed by the Python Software Foundation. Ubuntu is a registered trademark of Canonical Ltd.