Python base images with uv preinstalled
1.7K
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.
| Tag | Python | Contents |
|---|---|---|
latest, 3.14, 3.14.7 | 3.14 | production |
dev, 3.14-dev, 3.14.7-dev | 3.14 | development |
3.13, 3.13.14 | 3.13 | production |
3.13-dev, 3.13.14-dev | 3.13 | development |
3.12, 3.12.13 | 3.12 | production |
3.12-dev, 3.12.13-dev | 3.12 | development |
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.
Both images
uv and uvx on PATH (static binaries, version pinned per build)/opt/venv, first on PATHtini as PID 1 (reaps zombies — matters for Celery prefork and cron)gosu, cron, curl, ca-certificates, tzdata, libpq5app (1000:1000)-dev only
build-essential, gcc, g++, make, pkg-config, libpq-dev, libffi-dev, libssl-devgit, wget, unzip, jq, less, procps, vim-tiny, openssh-clientruff preinstalleddev-app helper script (project scaffolding, Node.js bootstrap)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.
/opt/venvNot 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.
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--reloadactually fires.
docker compose execbypasses the entrypoint and therefore runs as root, which leaves root-owned__pycache__in your working tree. Usedocker compose exec -u app app ..., or adduser: appto the service so everything runs as1000:1000. (Thecronrole is the one thing that needs root.)
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.
Pick one with CONTAINER_ROLE (default app).
| Role | What it runs |
|---|---|
app | uvicorn (or gunicorn, see APP_SERVER) |
worker, celery_worker | celery ... worker |
beat, celery_beat | celery ... beat |
flower, celery_flower | celery ... flower |
consumer, module | python -m $PYTHON_MODULE |
cron | cron -f |
command, custom | bash -c "$APP_COMMAND" |
shell, sleep | sleep 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)'
| Variable | Default | Purpose |
|---|---|---|
CONTAINER_ROLE | app | Which role to start |
APP_WORKDIR | /app | Working directory |
APP_USER | app | User to drop to; root disables the drop |
SKIP_ENTRYPOINT | – | true → sleep forever instead of starting |
TZ | Europe/Kyiv | Container timezone |
Run in this order, before the role starts, as APP_USER:
| Variable | Default | Purpose |
|---|---|---|
WAIT_FOR | – | db:5432,redis:6379 — block until each accepts connections |
WAIT_FOR_TIMEOUT | 60 | Seconds per dependency before failing |
RUN_UV_SYNC | 0 | 1 → uv sync on start |
UV_SYNC_ARGS | --frozen | Arguments for that sync |
RUN_MIGRATIONS | 0 | 1 → alembic upgrade head |
MIGRATIONS_REVISION | head | Target revision |
PRE_START_COMMAND | – | Arbitrary shell run last |
app role| Variable | Default | Purpose |
|---|---|---|
APP_SERVER | uvicorn | uvicorn or gunicorn |
APP_MODULE | app.main:app | ASGI application path |
APP_HOST | 0.0.0.0 | Bind address |
APP_PORT | 8000 | Bind port |
APP_WORKERS | 1 | Worker count (ignored while reloading) |
APP_RELOAD | 0 (dev: 1) | 1 → --reload, mutually exclusive with workers |
APP_RELOAD_DIR | dev: app | Space-separated dirs to watch |
APP_LOG_LEVEL | – | uvicorn log level |
APP_PROXY_HEADERS | 0 | 1 → --proxy-headers (behind nginx/traefik) |
APP_FORWARDED_ALLOW_IPS | * | Paired with the above |
APP_EXTRA_ARGS | – | Appended verbatim |
GUNICORN_WORKER_CLASS | uvicorn.workers.UvicornWorker | gunicorn only |
GUNICORN_TIMEOUT | 60 | gunicorn only |
| Variable | Default | Purpose |
|---|---|---|
CELERY_APP | required | e.g. app.celery:celery_app |
CELERY_QUEUES | – | --queues |
CELERY_CONCURRENCY | 4 | Lower bound / fixed concurrency |
CELERY_CONCURRENCY_MAX | = min | Set higher to switch on --autoscale |
CELERY_POOL | prefork | prefork, threads, gevent, … |
CELERY_LOG_LEVEL | INFO | Log level |
CELERY_EVENTS | 1 | 1 → -E |
CELERY_HOSTNAME | – | --hostname |
CELERY_WORKER_STATE_DB | – | --statedb |
CELERY_MAX_TASKS_PER_CHILD | – | Recycle workers after N tasks |
CELERY_BEAT_SCHEDULE_FILE | – | beat schedule path |
CELERY_EXTRA_ARGS | – | Appended verbatim |
FLOWER_PORT | 5555 | flower port |
FLOWER_BASIC_AUTH | – | user:pass |
FLOWER_DB | /tmp/flower.db | State file |
FLOWER_PERSISTENT | True | Persist state |
FLOWER_MAX_TASKS | 200000 | Tasks kept in memory |
FLOWER_STATE_SAVE_INTERVAL | 10000 | ms between saves |
FLOWER_URL_PREFIX | – | When mounted on a subpath |
| Variable | Purpose |
|---|---|
PYTHON_MODULE | consumer/module role: module for python -m |
PYTHON_MODULE_ARGS | Arguments appended to it |
APP_COMMAND | command role: shell command to run |
CRON_SCHEDULE | cron role: cron expression, default * * * * * |
CRON_COMMAND | cron role: command to run on that schedule |
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
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 . .
.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.UV_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.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.libpq5 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
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.envinstead:printf 'HOST_UID=%s\nHOST_GID=%s\n' "$(id -u)" "$(id -g)" > .env
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
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
MIT.
Content type
Image
Digest
sha256:efa61f8da…
Size
73.8 MB
Last updated
about 1 month ago
docker pull ymnik13/python-uv