Sign inSign up

ymnik13/python-uv

By ymnik13

Updated about 1 month ago

Python base images with uv preinstalled

Image
Languages & frameworks
Developer tools
0

1.7K

ymnik13/python-uv repository overview

ymnik13/python-uv

Python base images with uv preinstalled and a role-based entrypoint, built for FastAPI / Celery / worker services.

Two flavours per version: a slim production image and a dev image with a build toolchain and a project scaffolder.

Published on Docker Hub.

Tags

TagPythonContents
latest, 3.14, 3.14.73.14production
dev, 3.14-dev, 3.14.7-dev3.14development
3.13, 3.13.143.13production
3.13-dev, 3.13.14-dev3.13development
3.12, 3.12.133.12production
3.12-dev, 3.12.13-dev3.12development

latest / dev track 3.14. Pin the minor (3.14) in committed files; the full patch tag (3.14.7) is there when you need a frozen build.

Platforms: linux/amd64, linux/arm64.

Base: python:<version>-slim.

What's inside

Both images

  • uv and uvx on PATH (static binaries, version pinned per build)
  • a ready virtualenv at /opt/venv, first on PATH
  • tini as PID 1 (reaps zombies — matters for Celery prefork and cron)
  • gosu, cron, curl, ca-certificates, tzdata, libpq5
  • non-root user app (1000:1000)
  • the role-based entrypoint described below

-dev only

  • build-essential, gcc, g++, make, pkg-config, libpq-dev, libffi-dev, libssl-dev
  • git, wget, unzip, jq, less, procps, vim-tiny, openssh-client
  • ruff preinstalled
  • the dev-app helper script (project scaffolding, Node.js bootstrap)
  • reload mode on by default (APP_RELOAD=1)

Approximate sizes: production ~77 MB compressed (~300 MB on disk), dev ~222 MB compressed (~865 MB on disk). The tag list above shows the compressed numbers; docker images shows the unpacked ones.

Why the venv lives at /opt/venv

Not in /app/.venv. During development you bind-mount your project over /app, and a host .venv (wrong OS, wrong arch) would shadow the container's. Keeping it outside the mount makes bind mounts safe. UV_PROJECT_ENVIRONMENT points uv sync at it, so uv commands need no extra flags.

The trade-off: the venv lives in the container, not in your working tree. Mount a named volume at /opt/venv so it survives docker compose up recreations, and set RUN_UV_SYNC=1 to keep it in step with uv.lock. Both are shown below.

Quick start

Development
name: my-api

services:
  app:
    image: ymnik13/python-uv:3.14-dev
    container_name: my_api_app
    restart: unless-stopped
    working_dir: /app
    extra_hosts:
      - 'host.docker.internal:host-gateway'
    volumes:
      - .:/app
      - app-venv:/opt/venv     # keeps the venv across recreations
    env_file:
      - .env
    environment:
      CONTAINER_ROLE: app
      APP_MODULE: app.main:app
      APP_RELOAD: "1"
      APP_RELOAD_DIR: app      # watch only your code, not .git/docs
      RUN_UV_SYNC: "1"         # uv sync --frozen on every start
    ports:
      - "127.0.0.1:8000:8000"

volumes:
  app-venv:
docker compose up -d
docker compose exec app pytest -q
docker compose exec app ruff check .

On macOS, bind-mount file events do not reach the container. Add WATCHFILES_FORCE_POLLING: "true" so --reload actually fires.

docker compose exec bypasses the entrypoint and therefore runs as root, which leaves root-owned __pycache__ in your working tree. Use docker compose exec -u app app ..., or add user: app to the service so everything runs as 1000:1000. (The cron role is the one thing that needs root.)

Production

Dockerfile

FROM ymnik13/python-uv:3.14

# Dependencies first: this layer survives code changes.
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev --no-install-project

COPY . .
RUN uv sync --frozen --no-dev

ENV CONTAINER_ROLE=app \
    APP_MODULE=app.main:app \
    APP_WORKERS=4

docker-compose.yml

x-app: &app
  image: my-api:prod
  restart: always
  env_file: [ .env ]
  depends_on:
    db:
      condition: service_healthy

services:
  app:
    <<: *app
    environment:
      CONTAINER_ROLE: app
      APP_WORKERS: 4
      WAIT_FOR: "db:5432"
    ports:
      - "127.0.0.1:8000:8000"

  worker:
    <<: *app
    environment:
      CONTAINER_ROLE: worker
      CELERY_APP: app.celery:celery_app
      CELERY_QUEUES: high,default,low
      CELERY_CONCURRENCY: 4

  beat:
    <<: *app
    environment:
      CONTAINER_ROLE: beat
      CELERY_APP: app.celery:celery_app

The application process runs as app (uid 1000), not root — the entrypoint drops privileges before exec'ing your server.

Roles

Pick one with CONTAINER_ROLE (default app).

RoleWhat it runs
appuvicorn (or gunicorn, see APP_SERVER)
worker, celery_workercelery ... worker
beat, celery_beatcelery ... beat
flower, celery_flowercelery ... flower
consumer, modulepython -m $PYTHON_MODULE
croncron -f
command, custombash -c "$APP_COMMAND"
shell, sleepsleep infinity (attach and poke around)

An explicit command always wins over the role:

docker run --rm ymnik13/python-uv:3.14 python -c 'print(1)'

Environment variables

General
VariableDefaultPurpose
CONTAINER_ROLEappWhich role to start
APP_WORKDIR/appWorking directory
APP_USERappUser to drop to; root disables the drop
SKIP_ENTRYPOINTtrue → sleep forever instead of starting
TZEurope/KyivContainer timezone
Startup hooks

Run in this order, before the role starts, as APP_USER:

VariableDefaultPurpose
WAIT_FORdb:5432,redis:6379 — block until each accepts connections
WAIT_FOR_TIMEOUT60Seconds per dependency before failing
RUN_UV_SYNC01uv sync on start
UV_SYNC_ARGS--frozenArguments for that sync
RUN_MIGRATIONS01alembic upgrade head
MIGRATIONS_REVISIONheadTarget revision
PRE_START_COMMANDArbitrary shell run last
app role
VariableDefaultPurpose
APP_SERVERuvicornuvicorn or gunicorn
APP_MODULEapp.main:appASGI application path
APP_HOST0.0.0.0Bind address
APP_PORT8000Bind port
APP_WORKERS1Worker count (ignored while reloading)
APP_RELOAD0 (dev: 1)1--reload, mutually exclusive with workers
APP_RELOAD_DIRdev: appSpace-separated dirs to watch
APP_LOG_LEVELuvicorn log level
APP_PROXY_HEADERS01--proxy-headers (behind nginx/traefik)
APP_FORWARDED_ALLOW_IPS*Paired with the above
APP_EXTRA_ARGSAppended verbatim
GUNICORN_WORKER_CLASSuvicorn.workers.UvicornWorkergunicorn only
GUNICORN_TIMEOUT60gunicorn only
Celery roles
VariableDefaultPurpose
CELERY_APPrequirede.g. app.celery:celery_app
CELERY_QUEUES--queues
CELERY_CONCURRENCY4Lower bound / fixed concurrency
CELERY_CONCURRENCY_MAX= minSet higher to switch on --autoscale
CELERY_POOLpreforkprefork, threads, gevent, …
CELERY_LOG_LEVELINFOLog level
CELERY_EVENTS11-E
CELERY_HOSTNAME--hostname
CELERY_WORKER_STATE_DB--statedb
CELERY_MAX_TASKS_PER_CHILDRecycle workers after N tasks
CELERY_BEAT_SCHEDULE_FILEbeat schedule path
CELERY_EXTRA_ARGSAppended verbatim
FLOWER_PORT5555flower port
FLOWER_BASIC_AUTHuser:pass
FLOWER_DB/tmp/flower.dbState file
FLOWER_PERSISTENTTruePersist state
FLOWER_MAX_TASKS200000Tasks kept in memory
FLOWER_STATE_SAVE_INTERVAL10000ms between saves
FLOWER_URL_PREFIXWhen mounted on a subpath
Other roles
VariablePurpose
PYTHON_MODULEconsumer/module role: module for python -m
PYTHON_MODULE_ARGSArguments appended to it
APP_COMMANDcommand role: shell command to run
CRON_SCHEDULEcron role: cron expression, default * * * * *
CRON_COMMANDcron role: command to run on that schedule

Cron

cron starts with an empty environment, so the entrypoint snapshots the container env into /etc/container.env and the generated job sources it back:

scheduler:
  image: my-api:prod
  # The cron role keeps root - the daemon cannot run unprivileged. Only relevant
  # if you pinned `user: app` on the other services; the image starts as root and
  # drops privileges itself for every other role.
  environment:
    CONTAINER_ROLE: cron
    CRON_SCHEDULE: "*/5 * * * *"
    CRON_COMMAND: "python -m app.tasks.cleanup"

For several jobs, mount your own file over /etc/cron.d/app_cron:

* * * * * root . /etc/container.env; cd /app && python -m app.cron >> /proc/1/fd/1 2>&1

Compiling from source

The production image has no compilers on purpose. If a dependency has no wheel for your platform, build it in the -dev image and copy the finished venv:

FROM ymnik13/python-uv:3.14-dev AS builder
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-dev

FROM ymnik13/python-uv:3.14
COPY --from=builder /opt/venv /opt/venv
COPY . .

Notes

  • .python-version — if your project pins a version different from the image tag, uv will refuse to continue: the image sets UV_PYTHON_DOWNLOADS=never, so it will not silently fetch another interpreter. Match the tag to the pin, or drop the file.
  • Build cacheUV_CACHE_DIR is deliberately unset, so uv caches per user under $HOME. In your own Dockerfiles use a cache mount: RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev.
  • Host UID — the app user is 1000:1000. If your host user differs, either rebuild with --build-arg USER_UID=... --build-arg USER_GID=..., or set APP_USER=root.
  • Postgreslibpq5 ships in both images, so psycopg works without the [binary] extra. libpq-dev (headers) is dev-only.

dev-app (dev image only)

A helper for the boilerplate you would otherwise write by hand.

docker compose exec app dev-app          # interactive menu
docker compose exec app dev-app -h       # all commands
docker compose exec app dev-app info     # python / uv / venv versions
Scaffolding a project
docker compose exec app dev-app py new --fastapi --name my-api

Writes a complete uv project into the working directory, then runs uv lock and uv sync:

pyproject.toml     project + dev group + ruff/pytest config
uv.lock            resolved
.python-version    matching the image
app/               main.py, settings.py (pydantic-settings)
tests/             a passing health test
Dockerfile         production build
docker-compose.yml dev stack (bind mount, venv volume, reload)
Makefile           up / down / sync / lint / test / migrate / shell
.env.example       .gitignore  .dockerignore  README.md

Templates: --fastapi (default), --worker (Celery), --minimal.

It refuses to run if pyproject.toml already exists. Files are chowned to HOST_UID:HOST_GID when set, otherwise to the current user:

environment:
  HOST_UID: ${HOST_UID:-1000}
  HOST_GID: ${HOST_GID:-1000}

bash does not export UID/GID, so ${UID} in compose expands to an empty string. Write real values into .env instead:

printf 'HOST_UID=%s\nHOST_GID=%s\n' "$(id -u)" "$(id -g)" > .env
Other commands
dev-app py sync [args]     # uv sync (--frozen when uv.lock exists)
dev-app py add <pkg...>    # uv add
dev-app py lock            # uv lock
dev-app py lint            # ruff check .
dev-app py test [args]     # pytest
dev-app py shell           # REPL in the project venv
Node.js

For projects with a frontend build step. Installs nvm, then Node, and links it into PATH so external calls work:

docker compose exec app dev-app node install 22   # no version → latest LTS
docker compose exec app node -v

Node is installed into the running container, so it has to be repeated after a recreate. Bake it in to make it permanent:

FROM ymnik13/python-uv:3.14-dev
RUN dev-app node install 22

License

MIT.

Tag summary

Content type

Image

Digest

sha256:efa61f8da

Size

73.8 MB

Last updated

about 1 month ago

docker pull ymnik13/python-uv