Skip to content

Dockerizing an Application

This guide will show how to Dockerize a Python application written in a framework like Django.

It assumes you are using uv and that your project is in a git repository.

Writing a Dockerfile for a Python app

First, your project will need a Dockerfile; this should typically be in the root of the repository, next to pyproject.toml.

This is an example Dockerfile for a Python project:

# uv image based on Debian 13
FROM ghcr.io/astral-sh/uv:python3.14-trixie-slim

# optimized Python options for Docker
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy

# Example of adding additional packages. CHANGE extra-package
# RUN apt-get update \
#  && apt-get install -y --no-install-recommends extra-package \
#  && rm -rf /var/lib/apt/lists/*

# app user
RUN useradd -m -u 1000 app
WORKDIR /app

# dependencies only, layer is cached until pyproject/uv.lock change
COPY --chown=app:app pyproject.toml uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev --no-install-project

# copy code after cached venv layer
COPY --chown=app:app . .
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev

# directories app needs to write to
RUN mkdir -p /app/_logs /app/_staticfiles \
 && chown -R app:app /app/_staticfiles /app/_logs

# run remainder as app user
USER app
ENV PATH="/app/.venv/bin:$PATH"

# build staticfiles (Django)
RUN DEBUG=true SECRET_KEY=build-only python manage.py collectstatic --noinput

# run application via gunicorn
ENTRYPOINT []
EXPOSE 8000
CMD ["gunicorn", "config.wsgi:application", "--preload", "--bind", "0.0.0.0:8000", "--workers", "4", "--timeout", "60"]

Using the Image

A Docker image is a small declarative virtual filesystem that is configured to just run your application.

This image will eventually be combined with other services in a compose.yml.

Tip

If you are using NixOS, this step is baked in already. You will not need to do anything additional to build the image.

It may be useful to peek at example compose if you haven't seen a compose file before. (Your app will be taking the place of Miniflux.)

Building on the Server

Docker needs to be able to find your image. Images like postgres:18 are published to the public Docker registry. Publishing to a registry is the "right way", but comes with additional steps and complexity we don't always need.

The simplest option for now is to clone the project onto the server and build the Docker image there, bypassing the need for a registry.

Connect to the server and run:

git clone <your-git-repo> ~/app
cd ~/app
sudo docker build . -t app:latest

To confirm this build worked check that gunicorn runs inside the image:

sudo docker image ls app
sudo docker run --rm app:latest gunicorn --version

You should see an app with a recent build time and the version of gunicorn that is installed.

Integrating with compose

After building he image, you will be able to add it to your compose.yml like:

  app:
    # this name must match the docker build command
    image: app:latest
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    environment:
      # any needed environment variables go here
      # and can reference values from .env
      DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${POSTGRES_DB}?sslmode=disable
      DEBUG: "false"

Similarly, you'll need to update the Caddyfile to refer to your app and its port:

app.example.com { # CHANGE
    reverse_proxy app:8000
}

Updating

To deploy a new version of your app, you can log in to the server and run:

# rebuild app image
cd ~/app
git pull origin main
sudo DOCKER_BUILDKIT=1 docker build . -t app:latest
# restart app
cd ~/compose
sudo docker compose up -d app