DevOps study app: Containers

Table of Contents

Intro

We going to take the Python app we have in /src/ and upload it to Docker to make it a Docker image.

Example:

We going to test everything with a demo project: /src/

uv init demo

Containerized demo:

src/demo/Dockerfile:

FROM python:latest
WORKDIR /app
COPY . /app
CMD ["python", "main.py"]
  • Taking the latest Python image that is in docker-hub.
  • The working directory is /app
  • We coping all the content in . which is /src/demo into /app.
  • Run the command python main.py.

Build:

docker build -t demo:01 .

Output:

❯ docker build -t demo:01 .
[+] Building 54.3s (8/8) FINISHED                                          docker:default
 => [internal] load build definition from Dockerfile                                 0.1s
 => => transferring dockerfile: 107B                                                 0.0s
 => [internal] load metadata for docker.io/library/python:latest                     7.9s
 => [internal] load .dockerignore                                                    0.0s
 => => transferring context: 2B                                                      0.0s
 => [1/3] FROM docker.io/library/python:latest@sha256:5f69d22a88dd4cc4ee1576def19a  44.9s
 => => resolve docker.io/library/python:latest@sha256:5f69d22a88dd4cc4ee1576def19ae  0.0s
 => => sha256:0c01110621e0ec1eded421406c9f117f7ae5486c8f7b0a0d1a3 48.49MB / 48.49MB  8.3s
 => => sha256:b1b8a0660a31403a35d70b276c3c86b1200b8683e83cd77a92 64.40MB / 64.40MB  30.9s
 => => sha256:3b29f43b7fff1f92cbea7613c52afd7725af4fec4862126311438 6.32kB / 6.32kB  0.0s
 => => sha256:3b1eb73e993990490aa137c00e60ff4ca9d1715bafb8e888dbb 24.02MB / 24.02MB  5.9s
 => => sha256:5f69d22a88dd4cc4ee1576def19aef48c8faa1b566054c4429118 9.72kB / 9.72kB  0.0s
 => => sha256:1ae8a7a2503de66cd1398659363f8ea92db4a634bffab4dfb074a 2.32kB / 2.32kB  0.0s
 => => sha256:48b8862a18fa961ebfbac8484877dd4894e96ee88177d8c4 211.37MB / 211.37MB  37.5s
 => => extracting sha256:0c01110621e0ec1eded421406c9f117f7ae5486c8f7b0a0d1a37cc7bc9  1.3s
 => => sha256:20d0b4e6a2e6295e295119126082b4f39882f50278611e48580e 6.16MB / 6.16MB  10.1s
 => => extracting sha256:3b1eb73e993990490aa137c00e60ff4ca9d1715bafb8e888dbb0986275  0.4s
 => => sha256:77a6ac598bc154025b4b2e393a3ca959116e0d8af9c31659a8 27.39MB / 27.39MB  20.6s
 => => sha256:5cc4a19fbac0d0fb7423535182443188713730a05b7ab1104f211605 250B / 250B  26.3s
 => => extracting sha256:b1b8a0660a31403a35d70b276c3c86b1200b8683e83cd77a92ec987440  1.9s
 => => extracting sha256:48b8862a18fa961ebfbac8484877dd4894e96ee88177d8c4f1f54d9727  5.0s
 => => extracting sha256:20d0b4e6a2e6295e295119126082b4f39882f50278611e48580e8fbef7  0.2s
 => => extracting sha256:77a6ac598bc154025b4b2e393a3ca959116e0d8af9c31659a857ac05ab  0.6s
 => => extracting sha256:5cc4a19fbac0d0fb7423535182443188713730a05b7ab1104f21160554  0.0s
 => [internal] load build context                                                    0.1s
 => => transferring context: 527B                                                    0.0s
 => [2/3] WORKDIR /app                                                               1.1s
 => [3/3] COPY . /app                                                                0.1s
 => exporting to image                                                               0.1s
 => => exporting layers                                                              0.1s
 => => writing image sha256:80d267af8e21f5f8f01dc8fe411c48a3a4c1d1c1ef17875e38981ca  0.0s
 => => naming to docker.io/library/demo:01      

Now, we can see the docker images list with:

docker image ls

Output:

❯ docker image ls
REPOSITORY   TAG       IMAGE ID       CREATED          SIZE
demo         01        80d267af8e21   54 seconds ago   1.02GB

To run the container:

docker run demo:01

Output:

❯ docker run demo:01
Hello from demo!
This is a demo

We can get more information about the image with:

docker image inspect <image-name>
docker history <image-name>

Add layers:

FROM python:latest
WORKDIR /app

# Add some silly layers
RUN touch file1.txt
RUN echo "hello" > file2.txt
RUN mkdir -p /app/test

COPY . /app

CMD ["python", "main.py"]

Containerized the back-end

Dockerfile

Create a new Dockerfile under /src/backend

FROM python:latest
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /app

COPY . /app

RUN uv sync --locked --no-editable

CMD ["uv", "run", "study-tracker-api"]
  • We are copying from ghcr.io/astral-sh/uv:latest into /bin, /uv, and /uvx.
  • Work directory is app.
  • Copying everything from /src/backend into app.
  • Terminal will run uv run study-tracker-api.

Tag and build

We going to run 2 commends at once.

TAG=00 && docker build -t backend:$TAG .
  • Making a variable for the “TAG” so we can change it every build.
  • The build command.
docker image ls
REPOSITORY   TAG       IMAGE ID       CREATED             SIZE
backend      00        3c963e717333   About an hour ago   1.09GB

Trivy

Trivy: The all in one open source security scanner, find vulnerabilities (CVE) & miscommunications (IaC) across repositories, containers images, Kubernetes clusters, and more.

Install:

mise use trivy

Use Trivy:

TAG=00 && trivy image --format table --severity CRITICAL,HIGH backend:$TAG
  • “–format table”: the format of the data.
  • “–severity CRITICAL, HIGH”: what CVEs we want to see. This is just a little bit, there are a lot more and it’s like this most of the time.
Legend:                                                                         
- '-': Not scanned                                                              
- '0': Clean (no security findings detected)                                    
backend:00 (debian 12.11)                                                                                                                                        
Total: 178 (HIGH: 169, CRITICAL: 9)                                                                                                                              
│      Library       │ Vulnerability  │ Severity │    Status    │    Installed Version    │ Fixed Version │                            Title                             │                
├────────────────────┼────────────────┼──────────┼──────────────┼─────────────────────────┼───────────────┼──────────────────────────────────────────────────────────────┤                
│ icu-devtools       │ CVE-2025-5222  │ HIGH     │ affected     │ 72.1-3                  │               │ icu: Stack buffer overflow in the SRBRoot::addTag function│                    │                │          │              │                         │               │ https://avd.aquasec.com/nvd/cve-2025-5222                    │
├────────────────────┼────────────────┼──────────┤              ├─────────────────────────┼───────────────┼──────────────────────────────────────────────────────────────┤
│ libaom3            │ CVE-2023-6879  │ CRITICAL │              │ 3.6.0-1+deb12u1         │               │ aom: heap-buffer-overflow on frame size change               │
│                    │                │          │              │                         │               │ https://avd.aquasec.com/nvd/cve-2023-6879                    │
│                    ├────────────────┼──────────┼──────────────┤                         ├───────────────┼──────────────────────────────────────────────────────────────┤
│                    │ CVE-2023-39616 │ HIGH     │ will_not_fix │                         │               │ AOMedia v3.0.0 to v3.5.0 was discovered to contain an        │
│                    │                │          │              │                         │               │ invalid read mem...                                          │
│                    │                │          │              │                         │               │ https://avd.aquasec.com/nvd/cve-2023-39616                   │

Optimizing the image

Starting size: 1.09GB

Making the image we copying more specific: version, and slim variant. /src/backend/Dockerfile

FROM python:3.13-slim
WORKDIR /app
COPY . /app
CMD ["python", "main.py"]
After version specific: 190MB

We can see that even the Trivy report is better:

Other tag options

NOTE: with alpine tag I got 115MB.

Implement caching & multi-stage builds

/src/backend/Dockerfile:

FROM python:3.13-alpine AS builder


# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# Change the working directory to the `app` directory
WORKDIR /app

# Install dependencies in a cache layer. This speeds up build time
RUN --mount=type=cache,target=/root/.cache/uv \
  --mount=type=bind,source=uv.lock,target=uv.lock \
  --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
  uv sync --locked --no-install-project --no-editable

# Copy the project into the intermediate image
COPY . /app

# Sync the project and install it, now that we have access to the source code
RUN --mount=type=cache,target=/root/.cache/uv \
  uv sync --locked --no-editable

FROM python:3.13-alpine

# Copy the environment, but not the source code
COPY --from=builder --chown=app:app /app/.venv /app/.venv

# Run the application
CMD ["/app/.venv/bin/study-tracker-api"]
  • :
  • FROM python:3.13-alpine AS builder: making the image as small as possible.
    • AS builder: multi-stage build, which helps create smaller final images.
  • --mount=type=cache,target=/root/.cache/uv \ : Installing dependencies in a cached layer, and when it’s update it don’t need to pull all the dependencies again –> faster rebuild.
  • --mount=type=bind,source=uv.lock,target=uv.lock \: mounting files from local file system to the builder stage.
  • --mount=type=bind,source=pyproject.toml,target=pyproject.toml \: mounting files from local file system to the builder stage.
  • uv sync --locked --no-install-project --no-editable: install all the dependencies in the cache layer.
  • COPY --from=builder --chown=app:app /app/.venv /app/.venv:
Now the image is 56.4MB

Securing the image

The securing going to be through creating a non-root user and using him instead of root.

/src/backend/Dockerfile

FROM python:3.13-alpine AS builder

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# Change the working directory to the `app` directory
WORKDIR /app

# Install dependencies in a cache layer. This speeds up build time
RUN --mount=type=cache,target=/root/.cache/uv \
  --mount=type=bind,source=uv.lock,target=uv.lock \
  --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
  uv sync --locked --no-install-project --no-editable

# Copy the project into the intermediate image
COPY . /app

# Sync the project and install it, now that we have access to the source code
RUN --mount=type=cache,target=/root/.cache/uv \
  uv sync --locked --no-editable

FROM python:3.13-alpine

# Create a non-root user and group with specific IDs
RUN addgroup -S -g 1000 app && adduser -S -u 1000 -G app app

# Copy the environment, but not the source code
COPY --from=builder --chown=app:app /app/.venv /app/.venv

# Switch to the non-root user
USER app

# Expose the correct port
EXPOSE 22112

# Run the application
CMD ["/app/.venv/bin/study-tracker-api"]
  • RUN addgroup -S -g 1000 app && adduser -S -u 1000 -G app app: creating a non-root user.
  • USER app: switching to the user we created above.
  • EXPOSE 22112: exposing the right port.

Containerized the front-end

Dockerfile

Create a new Dockerfile under /src/backend

FROM python:latest
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

WORKDIR /app

COPY . /app

RUN uv sync --locked --no-editable

CMD ["uv", "run", "study-tracker-fronend"]

Tag and build

We going to run 2 commends at once.

TAG=00 && docker build -t frontend:$TAG .
Size: 1.07GB

Making the image we copying more specific: version, and slim variant. /src/frontend/Dockerfile

FROM python:3.13-slim
WORKDIR /app
COPY . /app
CMD ["python", "main.py"]
Size: 128MB

Implement caching & multi-stage builds

/src/frontend/Dockerfile:

FROM python:3.13-alpine AS builder


# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# Change the working directory to the `app` directory
WORKDIR /app

# Install dependencies in a cache layer. This speeds up build time
RUN --mount=type=cache,target=/root/.cache/uv \
  --mount=type=bind,source=uv.lock,target=uv.lock \
  --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
  uv sync --locked --no-install-project --no-editable

# Copy the project into the intermediate image
COPY . /app

# Sync the project and install it, now that we have access to the source code
RUN --mount=type=cache,target=/root/.cache/uv \
  uv sync --locked --no-editable

FROM python:3.13-alpine

# Copy the environment, but not the source code
COPY --from=builder --chown=app:app /app/.venv /app/.venv

# Run the application
CMD ["/app/.venv/bin/study-tracker-frontend"]
Size: 49.2MB

Securing the image

The securing going to be through creating a non-root user and using him instead of root.

/src/frontend/Dockerfile

FROM python:3.13-alpine AS builder

COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/

# Change the working directory to the `app` directory
WORKDIR /app

# Install dependencies in a cache layer. This speeds up build time
RUN --mount=type=cache,target=/root/.cache/uv \
  --mount=type=bind,source=uv.lock,target=uv.lock \
  --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
  uv sync --locked --no-install-project --no-editable

# Copy the project into the intermediate image
COPY . /app

# Sync the project and install it, now that we have access to the source code
RUN --mount=type=cache,target=/root/.cache/uv \
  uv sync --locked --no-editable

FROM python:3.13-alpine

# Create a non-root user and group with specific IDs
RUN addgroup -S -g 1000 app && adduser -S -u 1000 -G app app

# Copy the environment, but not the source code
COPY --from=builder --chown=app:app /app/.venv /app/.venv

# Switch to the non-root user
USER app

# Expose the correct port
EXPOSE 22111cd 

# Run the application
CMD ["/app/.venv/bin/study-tracker-frontend"]