TL;DR

Это Docker-шаблон для Python + Poetry, рассчитанный на реальную работу, а не учебные примеры: воспроизводимое окружение, удобный dev-workflow, отдельные сборки под прод, dev, Jupyter и AI-инструменты.

Автор использует его в основном для DS/ML-задач, где важнее скорость и предсказуемость, чем экономия пары мегабайт образа. Шаблон обкатан в бою, экономит время и легко кастомизируется под свои нужды.

👉 Репозиторий на GitHub

Почти каждый Python-проект начинается одинаково: выбрать версию Python, настроить зависимости, виртуальное окружение, переменные среды, команды запуска. На практике самые болезненные места здесь — управление зависимостями и воспроизводимость окружения: разные версии библиотек, несовпадающие Python, локальные костыли, которые сложно повторить на другой машине или сервере.

Docker помогает изолировать окружение, но сам по себе он не решает Python-специфичные задачи. Его нужно правильно наполнить: учесть работу Poetry, кеширование зависимостей, структуру проекта и базовые практики, которые одинаково хорошо работают и в разработке, и в продакшене. Именно такой шаблон мы и будем собирать дальше.

В этой статье мы шаг за шагом соберём базовый Docker-шаблон для Python с Poetry, который удобно использовать и для разработки, и для прода. В основе будет минимальное и воспроизводимое окружение, а всё остальное - Vim ка�� IDE, Jupyter, AI-инструменты вроде Codex или Gemini - вынесено в отдельные образы и слои, которые можно подключать по мере необходимости. Начнём с самого главного - разберём Dockerfile и поймём, как собрать прочную и расширяемую базу для Python-проекта.

Собираем базовый Dockerfile для Python и Poetry

Для этого шаблона мы будем использовать multistage-сборку Docker-образов. Это позволяет разделить один Dockerfile на логические слои: базовое Python-окружение, сборку зависимостей, production-образ и отдельные dev- и IDE-окружения. Каждый слой решает свою задачу и может использоваться независимо.

Такой подход даёт сразу несколько преимуществ: мы не тащим dev-инструменты в продакшен, переиспользуем общий базовый образ, ускоряем сборку за счёт кеширования и можем легко добавлять новые сценарии — например, Vim-IDE, Jupyter или AI-инструменты — не усложняя основную сборку. В результате один Dockerfile покрывает и разработку, и продакшен, оставаясь при этом читаемым и расширяемым.

В основе всех последующих образов лежит слой python-base. Это базовый образ, в котором мы один раз настраиваем всё, что относится к Python-окружению: систему, пользователя, Python нужной версии, виртуальное окружение и Poetry. Все остальные build-таргеты наследуются от него и не дублируют эту логику.

В этом блоке определим базовый образ python-base и набор аргументов сборки, управляющих конфигурацией контейнера: пользователем, системными зависимостями, версиями Python и Poetry, а также региональными настройками. Использование ARG позволяет параметризовать сборку и переиспользовать образ в разных сценариях без изменения кода Dockerfile:

FROM archlinux:base-devel AS python-base
ARG TZ=Asia/Vladivostok
ARG DOCKER_HOST_UID=1000
ARG DOCKER_HOST_GID=1000
ARG DOCKER_USER=devuser
ARG DOCKER_USER_HOME=/home/devuser
ARG MIRROR_LIST_COUNTRY=RU
ARG BUILD_PACKAGES="pyenv git gnupg sudo postgresql-libs mariadb-libs openmp"
ARG PYTHON_VERSION=3.14
ARG POETRY_VERSION=2.2.1

В качестве базового образа здесь используется archlinux:base-devel :harold: Такой выбор может показаться спорным: Arch это rolling-release дистрибутив, а значит, версии пакетов постоянно обновляются, что не всегда ассоциируется со стабильностью и предсказуемостью, особенно в прод окружениях. Кроме того, Arch реже встречается в Docker-примерах по сравнению с Debian или Ubuntu.

Тем не менее, для задач, которые решает этот шаблон, такой выбор вполне оправдан. Arch даёт доступ к актуальным версиям библиотек, языков и системных компонентов без подключения сторонних репозиториев, а образ base-devel сразу включа��т полный набор инструментов для сборки нативных зависимостей. Это упрощает установку Python-пакетов с C-расширениями и хорошо масштабируется: в следующих слоях мы будем добавлять Node.js, AI-инструменты и другие dev-утилиты, и Arch позволяет делать это напрямую, без лишних приседаний и усложнения сборки.

Важно отметить, что это именно шаблон, а не догма. При необходимости базовый дистрибутив можно без проблем заменить на Debian, Ubuntu или любой другой, сократив набор зависимостей и адаптировав Dockerfile под конкретные требования проекта или production-окружения.

Двигаемся дальше и переходим к системной подготовке контейнера:

RUN echo "* soft core 0" >> /etc/security/limits.conf && \
    echo "* hard core 0" >> /etc/security/limits.conf && \
    echo "* soft nofile 10000" >> /etc/security/limits.conf

Здесь задаём системные лимиты контейнера. Отключаем создание core-дампов, которые в большинстве случаев не нужны, и увеличиваем лимит на количество открытых файлов. Это снижает риск неожиданных runtime-ошибок и делает поведение приложения более стабильным под нагрузкой.

RUN sed -i 's/^UID_MAX.*/UID_MAX 999999999/' /etc/login.defs
RUN sed -i 's/^GID_MAX.*/GID_MAX 999999999/' /etc/login.defs
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime

Настраиваем системные параметры пользователей и времени. Увеличение UID_MAX и GID_MAX необходимо для работы в средах с удалёнными dev-боксами и кластерами, где идентификаторы пользователей часто выбираются из большого, нефиксированного диапазона. Такая настройка позволяет сборкам корректно работать при пробросе UID/GID хоста и избежать проблем с правами доступа. Установка таймзоны делает логи и поведение приложения более предсказуемыми.

RUN set -eux; \
	groupadd $DOCKER_USER --gid=$DOCKER_HOST_GID && \
	useradd --no-log-init -g $DOCKER_USER --uid=$DOCKER_HOST_UID \
  -d $DOCKER_USER_HOME -ms /bin/bash $DOCKER_USER
RUN mkdir /application && chown $DOCKER_USER:$DOCKER_USER /application

Подготавливаем окружение для работы внутри контейнера от непривилегированного пользователя. Это базовая практика безопасности и удобства: процессы не запускаются от root, а файлы не создают проблем с правами доступа на хосте. Пользователь создаётся с фиксированными UID/GID и заранее подготовленным рабочим каталогом /application, который далее будет монтироваться как рабочая директория проекта. Эта заготовка используется во всех следующих стадиях сборки, где мы будем устанавливать зависимости, подключать dev-инструменты и запускать приложение.

Настриваем зеркала Arch Linux и ставим системные зависимости, необходимые для дальнейшей сборки образа:

RUN set -eux; \
  tmp="$(mktemp)"; \
  if curl -fsSL \
      --connect-timeout 10 \
      --max-time 30 \
      --retry 5 \
      --retry-delay 1 \
      --retry-all-errors \
      "https://archlinux.org/mirrorlist/?country=${MIRROR_LIST_COUNTRY}&protocol=https&ip_version=4&use_mirror_status=on" \
    | sed -e 's/^\s*#Server/Server/' -e '/^\s*#/d' \
    > "$tmp" \
    && grep -q '^Server' "$tmp"; then \
      mv "$tmp" /etc/pacman.d/mirrorlist; \
  else \
      echo "WARN: mirrorlist update failed; keeping existing /etc/pacman.d/mirrorlist" >&2; \
      rm -f "$tmp"; \
  fi
RUN pacman -Syu --noconfirm && \
  pacman -S --noconfirm --needed $BUILD_PACKAGES && \
  pacman -Scc --noconfirm && \
  rm -rf /var/lib/pacman/sync/*

Дикий RUN с curl только на первый взгляд выглядит пугающе. Его задача простая: попытаться получить актуальный список зеркал Arch Linux для нужного региона и использовать их для установки пакетов. Делается несколько попыток с таймаутами и ретраями, потому что archlinux.org время от времени бывает недоступен (иногда по вполне понятным причинам вроде DDoS).

Если обновить список зеркал не удалось, то сборка не падает. В этом случае используется стандартный mirrorlist, уже встроенный в базовый образ. Такой подход делает сборку более устойчивой: мы ускоряем загрузку пакетов, когда всё работает нормально, но не ломаем процесс, если внешний сервис временно недоступен.

Дальше обновляем систему (у нас rolling-release!), устанавливаем набор системных зависимостей, заданный отдельной переменной и необходимый в основном для работы Python-пакетов. После установки кеш pacman очищается, чтобы не увеличивать размер итогового образа.

В dev-контейнере иногда требуется быстро установить или проверить что-то вручную, не пересобирая образ. Эта настройка позволяет пользователю выполнять команды с sudo без ввода пароля, что заметно упрощает такие временные эксперименты (в app сборке уберём):

RUN echo "${DOCKER_USER} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers

В этом блоке через pyenv ставим нужную версию Python и задаём эту версию как глобальную внутри контейнера:

ENV PYENV_ROOT=$DOCKER_USER_HOME/.pyenv
ENV PATH=$PYENV_ROOT/shims:$PYENV_ROOT/bin:$PATH
RUN pyenv install --skip-existing $PYTHON_VERSION && \
  pyenv global $PYTHON_VERSION && \
  pyenv rehash && \
  rm -rf "$PYENV_ROOT/cache" "$PYENV_ROOT/sources" /tmp/python-build*

Для установки Python используем pyenv. Такой подход позволяет легко менять версию Python, просто изменив одно значение в переменных сборки, без переписывания Dockerfile. Это удобно при поддержке нескольких проектов или тестировании на разных версиях интерпретатора.

Минус у этого решения тоже есть: итоговый образ может получаться немного больше по размеру по сравнению с вариантами на готовых Python-образах. Тем не менее, в контексте шаблона удобство и гибкость смены версии Python зачастую перевешивают этот недостаток и заметно экономят время в повседневной работе.

Дальше настраиваем Python: виртуальное окружение, переменные для работы и Poetry как основной инструмент управления зависимостями:

ENV PYTHONUNBUFFERED=1
ENV PIP_DEFAULT_TIMEOUT=100
ENV POETRY_NO_INTERACTION=1
ENV POETRY_HOME=/opt/poetry
ENV POETRY_CACHE_DIR=/var/cache/pypoetry
ENV PIP_CACHE_DIR=/var/cache/pip
ENV VIRTUAL_ENV=/opt/venv
RUN python -m venv --copies $VIRTUAL_ENV
ENV PATH=$VIRTUAL_ENV/bin:$PATH
RUN pip install --upgrade pip
RUN curl -sSL https://install.python-poetry.org | POETRY_VERSION=$POETRY_VERSION python -
ENV PATH=$POETRY_HOME/bin:$PATH
ENV PYTHONPATH=/application/src
ENV PROJECT_ROOT=/application
ENV HOME=$DOCKER_USER_HOME

Здесь всё достаточно стандартно для Python-проектов. Мы создаём отдельное виртуальное окружение внутри контейнера и настраиваем переменные, отвечающие за предсказуемую работу Python, pip и Poetry. Зависимости изолируем в venv, а кеши pip и Poetry выносим в отдельные каталоги, что удобно для повторных сборок и dev-сценариев. Poetry устанавливаем как основной инструмент управления зависимостями без интерактивных запросов, а базовые переменные вроде PYTHONPATH и PROJECT_ROOT упрощают дальнейшую работу с кодом и инструментами.

На этом этапе у нас готова базовая заготовка: воспроизводимое Python-окружение с Poetry, настроенным пользователем и системной базой. Этот слой служит фундаментом для всех последующих сборок. Дальше мы рассмотрим образы, которые собираются поверх этой базы и решают уже конкретные задачи — от установки зависимостей приложения до dev- и IDE-окружений.

Multistage-сборки поверх базового образа

Теперь давайте дополним наш Dockerfile и посмотрим на производные сборки, которые используют базовый образ python-base. Они позволяют из одного Dockerfile собрать образы для разных сценариев использования.

Начнём с образа для работы с зависимостями:

FROM python-base AS poetry
ARG DOCKER_HOST_UID=1000
ARG DOCKER_HOST_GID=1000
ARG DOCKER_USER=devuser
RUN mkdir -p $POETRY_CACHE_DIR && \
  chown -R $DOCKER_USER $POETRY_CACHE_DIR
RUN mkdir -p $PIP_CACHE_DIR && \
  chown -R $DOCKER_USER $PIP_CACHE_DIR
USER ${DOCKER_HOST_UID}:${DOCKER_HOST_GID}
WORKDIR /application

Этот образ специально делаем лёгким. Он не привязан к выполнению poetry install, поэтому можно свободно менять pyproject.toml и пересобирать актуальный poetry.lock, не затрагивая остальные этапы сборки. С��зданные каталоги кешей pip и Poetry мы позже привяжем к локальным томам в docker compose. Это позволит переиспользовать кеш между перезапусками контейнеров и заметно ускорить установку зависимостей при разработке.

Посмотрим внимательнее на этот блок:

...
ARG DOCKER_HOST_UID=1000
ARG DOCKER_HOST_GID=1000
...
USER ${DOCKER_HOST_UID}:${DOCKER_HOST_GID}
...

В последующих сборках этот приём будет повторяться — мы везде переключаемся на пользователя по UID/GID, а не по имени. Формально можно было бы использовать USER $DOCKER_USER, но в CI-сценариях с кешированием слоёв (например, при сборке через kaniko) это иногда приводит к ошибкам, так как Docker пытается разрешить пользователя через /etc/passwd, которого может не быть в кеше. Использование UID/GID делает сборку более устойчивой и предсказуемой в автоматизированных пайплайнах.

Идём дальше, к следующей сборке. Этот образ служит заготовкой для прод приложения и содержит только необходимые зависимости без dev-инструментов:

FROM python-base AS app-build
ARG DOCKER_HOST_UID=1000
ARG DOCKER_HOST_GID=1000
ARG DOCKER_USER=devuser
COPY src/ build/src
COPY README.md /build/
COPY pyproject.toml poetry.lock /build/
ARG POETRY_OPTIONS_APP="--only main --compile"
RUN poetry install $POETRY_OPTIONS_APP -n -v -C /build && \
  rm -rf $POETRY_CACHE_DIR/* && rm -rf $PIP_CACHE_DIR/*
RUN sed -i "/^${DOCKER_USER}[[:space:]]/d" /etc/sudoers
USER ${DOCKER_HOST_UID}:${DOCKER_HOST_GID}
WORKDIR /application

Здесь мы копируем исходный код и файлы проекта, устанавливаем только прод зависимости через Poetry и сразу очищаем кеши. Дополнительно убираем sudo, так как в production-образе он не нужен, переключаемся на непривилегированного пользователя.

А это самая интересная часть - dev-сборка, где мы устанавливаем зависимости для разработки, дополнительные инструменты вроде Vim, Node и AI-утилит и подготавливаем полноценное рабочее окружение внутри контейнера:

FROM python-base AS build-deps-dev
ARG DOCKER_USER=devuser
ARG VIM_PACKAGES="python vim ctags ripgrep bat npm nodejs-lts-jod openai-codex gemini-cli"
ARG POETRY_OPTIONS_DEV="--no-root --with-dev --compile"
RUN pacman -Sy --noconfirm && \
  pacman -S --noconfirm --needed $VIM_PACKAGES && \
  pacman -Scc --noconfirm && \
  rm -rf /var/lib/pacman/sync/*
COPY pyproject.toml poetry.lock /build/
RUN poetry install $POETRY_OPTIONS_DEV -n -v -C /build && \
  rm -rf $POETRY_CACHE_DIR/* $PIP_CACHE_DIR/*
RUN mkdir -p $POETRY_CACHE_DIR $PIP_CACHE_DIR && \
  chown -R $DOCKER_USER $POETRY_CACHE_DIR $PIP_CACHE_DIR
RUN mkdir -p $DOCKER_USER_HOME/.codex && \
  chown -R $DOCKER_USER $DOCKER_USER_HOME/.codex
RUN mkdir -p $DOCKER_USER_HOME/.gemini && \
  chown -R $DOCKER_USER $DOCKER_USER_HOME/.gemini
RUN mkdir -p $DOCKER_USER_HOME/.config && \
  chown -R $DOCKER_USER $DOCKER_USER_HOME/.config

За название аргумента VIM_PACKAGES не судите строго. Изначально шаблон развивался как сборка Vim-IDE внутри контейнера, и это имя осталось как рудимент ранних версий. Отдельно стоит обратить внимание на готовые пакеты codex и gemini. Их установка через системный пакетный менеджер даёт заметно меньший размер образа по сравнению с установкой через npm, а также упрощает и ускоряет сборку dev-окружения.

Созданные каталоги для codex, gemini и пользовательских конфигураций далее будут смонтированы в локальные тома в docker compose. Это позволяет переиспользовать учётные данные и настройки AI-агентов между перезапусками контейнеров и не настраивать их заново каждый раз.

Здесь мы делаем заготовку для будущих dev-сборок: переключаемся на непривилегированного пользователя и задаём рабочий каталог контейнера:

FROM build-deps-dev AS dev-build
ARG DOCKER_HOST_UID=1000
ARG DOCKER_HOST_GID=1000
ARG DOCKER_USER=devuser
USER ${DOCKER_HOST_UID}:${DOCKER_HOST_GID}
WORKDIR /application

Сборку Vim-IDE я намеренно убираю под кат. Это достаточно специфичная часть шаблона и нужна далеко не всем. Здесь настраивается полноценная IDE на базе Vim с плагинами, LSP и дополнительными инструментами. Подробно разбирать этот слой мы не будем - при необходимости все детали настройки можно посмотреть в README репозитория шаблона.

Скрытый текст
FROM build-deps-dev AS vim-ide
ARG DOCKER_HOST_UID=1000
ARG DOCKER_HOST_GID=1000
ARG DOCKER_USER=devuser
ARG DOCKER_USER_HOME=/home/devuser
USER ${DOCKER_HOST_UID}:${DOCKER_HOST_GID}
RUN curl -fLo $DOCKER_USER_HOME/.vim/autoload/plug.vim --create-dirs \
  https://raw.githubusercontent.com/junegunn/vim-plug/master/plug.vim
RUN curl -fLo $DOCKER_USER_HOME/.vim/spell/en.utf-8.spl \
  --create-dirs https://ftp.nluug.nl/pub/vim/runtime/spell/en.utf-8.spl
RUN curl -fLo $DOCKER_USER_HOME/.vim/spell/en.utf-8.sug \
  --create-dirs https://ftp.nluug.nl/pub/vim/runtime/spell/en.utf-8.sug
RUN curl -fLo $DOCKER_USER_HOME/.vim/spell/ru.utf-8.spl \
  --create-dirs https://ftp.nluug.nl/pub/vim/runtime/spell/ru.utf-8.spl
RUN curl -fLo $DOCKER_USER_HOME/.vim/spell/ru.utf-8.sug \
  --create-dirs https://ftp.nluug.nl/pub/vim/runtime/spell/ru.utf-8.sug
COPY --chown=$DOCKER_USER:$DOCKER_USER .vimrc $DOCKER_USER_HOME/.vimrc
RUN cat $DOCKER_USER_HOME/.vimrc \
  |sed -n '/plug#begin/,/plug#end/p' > $DOCKER_USER_HOME/.vimrc_plug
RUN vim -u $DOCKER_USER_HOME/.vimrc_plug +'PlugInstall --sync' +qa
RUN vim -u $DOCKER_USER_HOME/.vimrc_plug \
  +'CocInstall -sync coc-pyright coc-json coc-yaml coc-snippets coc-markdownlint' +qa
COPY --chown=$DOCKER_USER:$DOCKER_USER .coc-settings.json \
  $DOCKER_USER_HOME/.vim/coc-settings.json
RUN git config --global --add safe.directory /application
ENV TERM=xterm-256color
WORKDIR /application

Почему я всё-таки упоминаю эту сборку? Потому что это наглядный пример того, как можно дальше кастомизировать dev-сборки, добавляя специализированные окружения поверх build-deps-dev под конкретные задачи и рабочие привычки.

В итоге из одного Dockerfile мы получили несколько сборок под разные задачи: прод, dev и специализированные окружения. Базу не трогаем, нужное просто надстраиваем — аккуратно и без лишней магии. А теперь давайте всё это соберём.

docker compose: один файл - много сценариев

Для сборок и запусков нам понадобится .env-файл — это место, где удобно управлять всеми настройками. Здесь задаются версии Python и Poetry, набор системных пакетов, параметры окружения и секреты вроде API-ключей. Меняя значения в .env, можно адаптировать весь проект под другую машину или сценарий запуска, не трогая Dockerfile и docker-compose.

.env.dist
# compose env file
COMPOSE_PROJECT_NAME="python-docker-template"

TZ=Asia/Vladivostok
DOCKER_HOST_UID=1000
DOCKER_HOST_GID=1000
DOCKER_USER=developer
DOCKER_USER_HOME=/home/developer
DOCKER_PLATFORM=linux/amd64

MIRROR_LIST_COUNTRY=RU
BUILD_PACKAGES="pyenv git gnupg sudo postgresql-libs mariadb-libs openmp"
VIM_PACKAGES="python vim ctags ripgrep bat npm nodejs-lts-jod openai-codex gemini-cli"

PYTHON_VERSION=3.14
PYTHONUNBUFFERED=1
PIP_DEFAULT_TIMEOUT=100
POETRY_VERSION=2.2.1
POETRY_OPTIONS_APP="--only main --compile"
POETRY_OPTIONS_DEV="--no-root --with dev --compile"
POETRY_NO_INTERACTION=1

JUPYTER_TOKEN=_change_me_please_!1_
OPENAI_API_KEY="sk-xxxxxxxxxxxxxxxxxxxx"
GEMINI_API_KEY="YOUR_GEMINI_API_KEY"

Отдельно стоит обратить внимание на DOCKER_HOST_UID и DOCKER_HOST_GID. Эти значения важно задать корректно под вашего пользователя на хосте — от них зависит, с какими правами контейнер будет работать с примонтированными файлами. На Linux и macOS их можно посмотреть командой:

id

Если UID/GID совпадают, вы избавляетесь от классических проблем с правами доступа и «root-файлами» в рабочем каталоге.

Для начала посмотрим на общие настройки и якоря compose, через которые мы централизованно прокидываем переменные окружения и аргументы сборки во все сервисы:

Скрытый текст
x-default-env: &default-env
  POETRY_NO_INTERACTION: "1"
  PIP_DEFAULT_TIMEOUT: "100"
  PYTHONUNBUFFERED: "1"

x-platform: &platform ${DOCKER_PLATFORM:-linux/amd64}

x-default-args: &default-args
  TZ: ${TZ:-Asia/Vladivostok}
  DOCKER_HOST_UID: ${DOCKER_HOST_UID:-1000}
  DOCKER_HOST_GID: ${DOCKER_HOST_GID:-1000}
  DOCKER_USER: ${DOCKER_USER:-developer}
  DOCKER_USER_HOME: ${DOCKER_USER_HOME:-/home/developer}
  MIRROR_LIST_COUNTRY: ${MIRROR_LIST_COUNTRY:-RU}
  BUILD_PACKAGES: ${BUILD_PACKAGES:-pyenv git gnupg sudo postgresql-libs mariadb-libs openmp}
  VIM_PACKAGES: ${VIM_PACKAGES:-python vim ctags ripgrep bat npm nodejs-lts-jod openai-codex gemini-cli}
  PYTHON_VERSION: ${PYTHON_VERSION:-3.14}
  POETRY_VERSION: ${POETRY_VERSION:-2.2.1}
  POETRY_OPTIONS_APP: ${POETRY_OPTIONS_APP:---only main --compile}
  POETRY_OPTIONS_DEV: ${POETRY_OPTIONS_DEV:---no-root --with dev --compile}

compose по умолчанию читает значения переменных из .env-файла, а если каких-то параметров там нет, используются значения по умолчанию, заданные прямо в конфигурации.

Теперь кратко рассмотрим сервисы, описанные в compose, и какие задачи каждый из них решает.

сервисы в compose.yaml
services:
  python-base:
    platform: *platform
    build:
      target: python-base
      args: *default-args
    environment:
      <<: *default-env
  poetry:
    platform: *platform
    entrypoint: poetry
    build:
      target: poetry
      args: *default-args
    environment:
      <<: *default-env
    volumes:
      - type: bind
        source: .
        target: /application
      - type: volume
        source: pip-cache
        target: /var/cache/pip
      - type: volume
        source: poetry-cache
        target: /var/cache/pypoetry
  vim-ide:
    platform: *platform
    entrypoint: vim
    build:
      target: vim-ide
      args: *default-args
    environment:
      <<: *default-env
      OPENAI_API_KEY: ${OPENAI_API_KEY}
      GEMINI_API_KEY: ${GEMINI_API_KEY}
    volumes:
      - type: bind
        source: .
        target: /application
      - type: volume
        source: gemini-auth
        target: ${DOCKER_USER_HOME}/.gemini
      - type: volume
        source: codex-auth
        target: ${DOCKER_USER_HOME}/.codex
  gemini:
    platform: *platform
    entrypoint: gemini
    build:
      target: dev-build
      args: *default-args
    environment:
      <<: *default-env
      GEMINI_API_KEY: ${GEMINI_API_KEY}
    volumes:
      - type: bind
        source: .
        target: /application
      - type: volume
        source: gemini-auth
        target: ${DOCKER_USER_HOME}/.gemini
  codex:
    platform: *platform
    entrypoint: codex
    build:
      target: dev-build
      args: *default-args
    environment:
      <<: *default-env
      OPENAI_API_KEY: ${OPENAI_API_KEY}
    volumes:
      - type: bind
        source: .
        target: /application
      - type: volume
        source: codex-auth
        target: ${DOCKER_USER_HOME}/.codex
  codex-web-login:
    platform: *platform
    entrypoint: codex login
    build:
      target: dev-build
      args: *default-args
    environment:
      <<: *default-env
    network_mode: host
    volumes:
      - type: volume
        source: codex-auth
        target: ${DOCKER_USER_HOME}/.codex
  jupyterlab:
    platform: *platform
    entrypoint:
      - jupyter-lab
      - --port=8888
      - --ip="0.0.0.0"
      - --no-browser
      - --IdentityProvider.token=${JUPYTER_TOKEN}
    ports:
      - "8888:8888"
    build:
      target: dev-build
      args: *default-args
    environment:
      <<: *default-env
    volumes:
      - type: bind
        source: .
        target: /application
  app:
    platform: *platform
    entrypoint: template_bin
    build:
      target: app-build
      args: *default-args
    environment:
      <<: *default-env
volumes:
  pip-cache:
    driver: local
  poetry-cache:
    driver: local
  codex-auth:
    driver: local
  gemini-auth:
    driver: local

Вся конфигурация сделана так, чтобы для работы с проектом было достаточно одной команды вида docker compose run --rm <service_name>. Если собрать всё, что мы разобрали выше, то использование шаблона сводится к нескольким понятным сценариям.

Инициализация проекта начинается с правок в .env. Здесь мы задаём версии Python и Poetry, UID/GID пользователя, системные пакеты и, при необходимости, API-ключи. После этого вся дальнейшая работа идёт через compose, без ручной настройки окружения на хосте, например:

cp .env.dist .env
docker compose build vim-ide
docker compose run --rm vim-ide

Работа с зависимостями вынесена в отдельный сервис, сначала обновляем pyproject.toml, затем пересобираем poetry.lock:

docker compose run --rm poetry lock

Важно помнить, что после обновления зависимостей (изменений в pyproject.toml или poetry.lock) соответствующие образы нужно пересобрать. Обычно это делается точечно - только для тех сервисов, которые эти зависимости используют:

docker compose build <service_name>

Например, после изменения зависимостей потребуется пересобрать vim-ide, dev-build или app, в зависимости от того, какой сценарий вы планируете запускать дальше.

Jupyter и эксперименты - отдельный сценарий, но всё в том же окружении. Код примонтирован как volume, зависимости уже установлены, права совпадают с хостом можно работать сразу:

docker compose run --rm --service-ports jupyterlab

А прод сборка - это уже минимальный образ без dev-инструментов:

docker compose build app
docker compose run --rm app

Ну куда же сейчас без AI-агентов - конечно, и в этом шаблоне они тоже есть. Правда, без лишней магии: здесь они представлены в виде CLI-инструментов вроде Codex и Gemini.

Запуск этих инструментов внутри контейнера даёт приятный бонус: изоляцию и безопасность. CLI работает только с теми файлами проекта, которые примонтированы в контейнер, и не имеет доступа к остальной файловой системе хоста. Это снижает риски, упрощает контроль над тем, что именно видит инструмент, и делает такой workflow более предсказуемым.

При этом подход остаётся удобным: AI-утилиты запускаются из консоли, в том же окружении, где живёт код и зависимости проекта. Их можно использовать как вспомогательный инструмент — подсмотреть структуру проекта, задать вопрос по файлу или быстро накидать заготовку — не выходя из привычного рабочего процесса.

Дальше мы посмотрим, как именно запускаются Codex и Gemini, как устроена авторизация и почему всё это не требует повторной настройки при каждом запуске контейнера.

Если вы богаты, успешны и у вас уже есть API-ключи для OpenAI и Gemini — всё максимально просто. Достаточно прописать их в .env:

OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxx
GEMINI_API_KEY=your_gemini_api_key

После этого AI-инструменты готовы к работе без дополнительных шагов. Запуск сводится к одной команде:

docker compose run --rm codex
# или
docker compose run --rm gemini

В следующем шаге разберём более «приземлённый» сценарий — когда API-ключей нет, но попробовать всё равно хочется.

Для Codex используется авторизация через браузер (для платных подписок chatgpt), без явного API-ключа. Достаточно запустить специальный сервис:

docker compose run --rm codex-web-login

Переходите по ссылке в браузере на вашем хосте, залогинитесь в OpenAI, а полученные токены сохранятся в volume. После этого Codex можно запускать обычным способом:

docker compose run --rm codex

Для Gemini всё ещё проще — при первом запуске:

docker compose run --rm gemini

достаточно выбрать вход через Google-аккаунт и вбить токен выданный на странице. Важно что бы аккаунт был "личный", то есть без привязки к workspace.

В обоих случаях токены и настройки живут отдельно от контейнеров и не теряются между перезапусками, а сам workflow остаётся тем же самым: docker compose run --rm <service>.

Вместо выводов

В итоге этот шаблон - не попытка изобрести универсальный «правильный Docker для всех», а практичная заготовка, которую автор использует в первую очередь для DS/ML-сценариев. Там, где код часто запускается на удалённых машинах, в кластерах или на специфичном железе, особенно важно, чтобы окружение было воспроизводимым и одинаковым везде.

Да, можно долго оптимизировать размер образов, но в реальной жизни эта экономия обычно рушится в тот момент, когда в проекте появляется tensorflow или pytorch. Поэтому здесь сделан осознанный выбор в пользу удобства, гибкости и скорости работы, а не борьбы за каждый мегабайт.

Все сборки и сценарии обкатаны в боевых условиях и действительно экономят время - как на старте проекта, так и при ежедневной работе. А дальше всё просто: это всего лишь шаблон. Его можно упрощать, усложнять, менять базовый дистрибутив, убирать Vim, добавлять свои сервисы - подстраивайте его под себя и свой workflow, а не наоборот.

Если этот шаблон сэкономит вам хотя бы пару часов — значит, он уже отработал своё.