Python 3.12 on Ubuntu 24.04 LTS on Azure User Guide
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, withpython3.12-dev,python3.12-venvandpython3-pip, all at3.12.3-1ubuntu0.15 -
A complete native build toolchain:
gcc,g++,gfortran,make,cmake,ninjaandpkg-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/pydataholding the pip cache, the pipx home,venvs/andworkspace/, 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 ed25519if 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
- In the Azure Portal choose Create a resource and search the Marketplace for Python 3.12 on Ubuntu 24.04 LTS by cloudimg.
- Select the offer, choose the plan, and click Create.
- 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. - On Disks, leave the attached 32 GiB data disk in place — it is where the caches and your workspace live.
- On Networking, restrict inbound SSH (22) to your own IP address. Do not open any other port; this image serves nothing else.
- 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

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

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

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/python3and any virtual environment under/srv/pydata/venvs/. Select the project's environment with Python: Select Interpreter. - Neovim, Helix, Zed, Emacs, Kate:
pylspis installed and on thePATH, so point your LSP client at thepylspcommand. 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 ofpython-lsp-serverpathspec(MPL-2.0), a dependency ofblack
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

Notes on the posture:
- No credentials ship in the image. There is no service to authenticate to.
/var/lib/cloudimg/python-workspace.envis 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-packagesis possible but is not the supported workflow; use a virtual environment orpipx. - 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.