Building a Heroku Buildpack to Work with the uv Package Manager

Be it for our own internal tooling or on our client’s projects, we’ve used a variety of package managers. When building our newest tool, we decided to try out the uv opens a new window package manager that’s been talked about a lot.

One problem with that, though, is that lack of support for uv in Heroku’s Python buildpacks. There’s an issue open in the Heroku roadmap opens a new window to support it, but it is not supported yet.

We weren’t going to let that little hiccup stop us from using uv though. Inspired by the python-poetry-buildpack opens a new window , we built our own little heroku-uv-buildpack opens a new window that allowed us to deploy our application to Heroku.

Created by Astral, the same folks behind the ruff linter and formatter, uv is a very powerful package manager (and more) that allows you to easily manage your Python environments and dependencies.

Our focus here is on the buildpack, but if you’re curious to read more about the tool, check out this uv Deep Dive article by SaaS Pegasus opens a new window .

Heroku Buildpacks

Per Heroku’s documentation on buildpacks:

Buildpacks are a set of scripts that transform code into a deployable artifact with minimal configuration that gets executed on a dyno.

Heroku has officially supported buildpacks that provide support for several languages and frameworks, including Python. It also allows you to create custom buildpacks and to compose multiple buildpacks, which run in sequence.

We’ll take advantage of this ability to compose buildpacks. Instead of creating a brand new Python buildpack that supports uv, we’ll just create a small buildpack that prepares the resources the official Heroku Python buildpack is expecting from our uv-specific files.

Buildpack Composition

A buildpack consists of three scripts:

  • bin/detect: determines whether the buildpack should be applied to an application.
  • bin/compile: performs the transformation steps on the application. This defines what our buildpack actually does.
  • bin/release: provides metadata that is sent back to the runtime.

We’ll define bin/detect and bin/compile. We don’t really need bin/release for our purposes here.

bin/detect

This is what determines whether to apply our buildpack to an app or not. In our case, all of our transformations rely on the application having a uv.lock file. So our bin/detect script will simply check if one is present and, if not, exit with an error:

#!/usr/bin/env bash

set -euo pipefail

BUILD_DIR="$1"

if [ ! -f "$BUILD_DIR/uv.lock" ] ; then
    exit 1
fi

echo "Python uv"

bin/compile

This is where the steps the buildpack will apply to the application are defined. In order to get the resources in place the Heroku Python buildpack needs, we’ll perform two transformations:

  1. Generate a requirements.txt file with uv
  2. Generate a runtime.txt file

Install uv

The first thing we need to do is install the right version uv. A specific uv version can be set using a UV_VERSION config var. If a UV_VERSION is not specified, we’ll default to latest:

UV_VERSION="${UV_VERSION:-}"

if [ -z "$UV_VERSION" ] ; then
  log "No uv version specified in the UV_VERSION config var. Defaulting to latest."
else
  log "Using uv version from UV_VERSION config var: $UV_VERSION"
fi

With the version set, we can download and install uv. At this stage, we don’t have Python (or pip) installed. We’ll just use the curl option to install uv:

log "Install uv"

if [ -n "$UV_VERSION" ]; then
  UV_URL="https://astral.sh/uv/$UV_VERSION/install.sh"
else
   UV_URL="https://astral.sh/uv/install.sh"
fi

if ! curl -LsSf "$UV_URL" | sh ; then
  echo "Error: Failed to install uv."
  exit 1
fi

If installed successfully, we then need to add it to the PATH and it will be ready to use!

log "Add uv to the PATH"
export PATH="/app/.local/bin:$PATH"

Generate requirements.txt

Now we can generate the requirements.txt file using uv. We’ll leverage it’s support of pip commands:

log "Export $REQUIREMENTS_FILE from uv"

cd "$BUILD_DIR"

uv venv
source .venv/bin/activate
if [ "${UV_EXPORT_DEV_REQUIREMENTS:-0}" == "0" ] ; then
  uv sync --no-dev
else
  uv sync
fi
uv pip freeze > requirements.txt

We’re doing a few things there.

First, we navigate to the location of the app (BUILD_DIR) and then create and activate a virtual environment.

The buildpack provides an option to control whether development dependencies should be installed or not, UV_EXPORT_DEV_REQUIREMENTS. We check if it is set or not and run the appropriate version of the uv sync command.

Finally, we leverage pip freeze to create the requirements.txt file.

Generate runtime.txt

With the requirements.txt file created, we’ve completed the first transformation we set out to do. Let’s move on to the second, creating the runtime.txt file.

If you’d rather create the runtime.txt file in your repository and skip this step, you can set DISABLE_UV_CREATE_RUNTIME_FILE to 1. We’ll check to see if it is set and skip generation if it is:

if [ "${DISABLE_UV_CREATE_RUNTIME_FILE:-0}" != "0" ] ; then
  log "Skip generation of $RUNTIME_FILE file from uv.lock"
  exit 0
fi

If it is not set, however, a runtime.txt file should not already be present. If it is, we’ll exit with an error:

if [ -f "$RUNTIME_FILE" ] ; then
  log "$RUNTIME_FILE found, delete this file from your repository!" >&2
  exit 1
fi

In order to create that file, we need to define the Python runtime version. It is possible to force a Python version by setting the PYTHON_RUNTIME_VERSION config var. If it is not set, the Python runtime version will be taken from the uv.lock file:

if [ -z "${PYTHON_RUNTIME_VERSION:-}" ] ; then
  log "Read Python version from uv.lock"
  PYTHON_RUNTIME_VERSION=$(head --lines=2 "uv.lock" | sed -nE 's/^requires-python[[:space:]]*=[[:space:]]*"==([0-9.]+)"/\1/p')
else
  log "Force Python version to $PYTHON_RUNTIME_VERSION since the $PYTHON_RUNTIME_VERSION environment variable is set!"
fi

Finally, if we find a runtime version, we create the runtime.txt:

if [[ -n "$PYTHON_RUNTIME_VERSION" ]] ; then
  log "PYTHON_RUNTIME_VERSION is set to $PYTHON_RUNTIME_VERSION"
  echo "python-$PYTHON_RUNTIME_VERSION" > "$RUNTIME_FILE"
else
  log "$PYTHON_RUNTIME_VERSION is not valid, please specify an exact Python version (e.g. ==3.12.6) in your pyproject.toml (so it can be properly set in uv.lock)" >&2
  exit 1
fi

Using the buildpack

This buildpack needs to be used together with Heroku’s official Python buildpack. You can add buildpacks to Heroku using the Heroku CLI and buildpacks:add:

heroku buildpacks:clear
heroku buildpacks:add https://github.com/ombulabs/heroku-uv-buildpack
heroku buildpacks:add heroku/python

With the buildpacks in place, you can now deploy your Python application with uv to Heroku.