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
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 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
, we built our own little heroku-uv-buildpack
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 .
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:
- Generate a
requirements.txt
file withuv
- 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.