Hey there! I recently built a REST API to manage student records using Flask, Flask-SQLAlchemy, and SQLite, and shared it in my last post. Now, I have taken it to the next level by containerizing it with Docker, making it portable and consistent across machines.

In this post, I will walk you through how I Dockerized my API, including the mistakes I made and how I fixed them, so you can follow along or avoid my pitfalls.


Why Docker?

Docker packages your app with its dependencies into a container, ensuring it runs the same everywhere, your laptop, a server, or the cloud. For my Student API, this meant no more “it works on my machine” excuses.

Let’s dive into the steps I took, bugs and all.


Step 1: Writing the Dockerfile

I started by creating a Dockerfile in my project folder (student-api-new/) to define how to build the Docker image. I used a multi-stage build to keep the image small: one stage for installing dependencies, another for running the app.

Stage 1: Build dependencies

# Use the slim version of Python 3.13 as base image
FROM python:3.13-slim AS builder

# Set working directory in the container
WORKDIR /app

# Update package list
RUN apt-get update

# Install build tools like gcc, then clean up
RUN apt-get install -y --no-install-recommends gcc && \
    apt-get autoremove -y && \
    rm -rf /var/lib/apt/lists/*

# Copy the requirements file into the container
COPY requirements.txt .

# Install required Python packages without caching
RUN pip install --no-cache-dir -r requirements.txt

# Install Gunicorn explicitly for running the app
RUN pip install --no-cache-dir gunicorn==23.0.0

Stage 2: Runtime image

# Start again from a clean slim Python image
FROM python:3.13-slim

# Set working directory again for final image
WORKDIR /app

# Copy installed Python packages from the builder stage
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages

# Copy Gunicorn from builder stage
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/gunicorn

# Copy your actual Flask app code
COPY app/ ./app/

# Create non-root user for better security
RUN useradd -m appuser && chown -R appuser:appuser /app

# Switch to the new user
USER appuser

# Expose the port the app runs on
EXPOSE 5000

# Set the Flask app environment variable
ENV FLASK_APP=app

# Command to run the Flask app with Gunicorn
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "app:create_app()"]

What I Learned

  • Multi-Stage Builds: The builder stage handles heavy lifting (like installing gcc), but the final image only includes essentials, keeping it around 235MB.
  • Slim Base Image: python:3.13-slim is lighter than the full Python image.
  • Non-Root User: Using appuser improves security for production.

Step 2: Adding a .dockerignore

To reduce the image size, I created a .dockerignore file to exclude unnecessary files, like my virtual environment and test folder:

.venv/
.env
__pycache__/
*.pyc
students.db
.git/
.gitignore
tests/
README.md
Makefile

This ensured only the app code and dependencies went into the container, saving space.


Step 3: Building the Docker Image

I built the image with a semantic version tag (avoiding latest, which I learned is a no-no for reproducibility):

# Build the Docker image and tag it
docker build -t student-api:1.0.0 .

Step 4: Running the Container

To run the API, I used a command that maps port 5000 and loads environment variables from a .env file:

# Run the container in detached mode and pass in environment variables
docker run -d -p 5000:5000 --env-file .env student-api:1.0.0

Contents of my .env file:

FLASK_ENV=development
DATABASE_URL=sqlite:///students.db
SECRET_KEY=mysecretkey

Step 5: Testing the API

With the container running, I tested it using curl:

# Check if the API is healthy
curl http://localhost:5000/api/v1/healthcheck

Output:

## Output: {"status":"healthy"}
# Add a student record
curl -X POST http://localhost:5000/api/v1/students \
-H "Content-Type: application/json" \
-d '{"name": "Alice", "age": 20}'

Output:

{"id":1,"name":"Alice","age":20}

Seeing those responses felt like such a huge win considering all the errors I encountered 🥲 My API was finally alive in Docker.


Step 6: Updating Documentation

I updated my README.md to include Docker instructions, so anyone could try it.

Setup (Docker)

Prerequisites

  • Docker installed (docker --version)

Build the Image

make docker-build

Run the Container

make docker-run

I also added Makefile targets to simplify things:

# Makefile for building and running Docker image

docker-build:
    docker build -t student-api:1.0.0 .

docker-run:
    docker run -d -p 5000:5000 --env-file .env student-api:1.0.0

What I Learned: Good docs make your project accessible. The Makefile was a lifesaver for quick commands.


Step 7: Committing to GitHub

I wanted to share my Docker setup on GitHub, so I committed the Dockerfile and other files:

# Add Docker setup files to Git
git add Dockerfile .gitignore

# Commit with message
git commit -m "Add Docker setup for Student API"

# Push to GitHub
git push origin main

Errors I Ran Into (and How I Fixed Them)

1. Slow Build Time (17+ Minutes)

Error:

[+] Building 1050.2s (8/12)
=> [builder 4/4] RUN apt-get update && apt-get install ... 970.8s

Why: My internet connection was slow, which bogged down apt-get.

Fix:

# Check network speed
curl -o /dev/null http://deb.debian.org/debian/dists/bookworm/InRelease

# Clear Docker build cache
docker builder prune

Also split apt-get update into a separate RUN to cache it better. My build time dropped to ~1–2 minutes afterwards.


2. Gunicorn Not Found

Error:

docker: Error response from daemon: ... exec: "gunicorn": executable file not found in $PATH

Why: I forgot to add gunicorn to requirements.txt, and didn’t rebuild properly.

Fix:

# requirements.txt
Flask==3.1.0
Flask-SQLAlchemy==3.1.1
gunicorn==23.0.0

Then in Dockerfile:

RUN pip install --no-cache-dir gunicorn==23.0.0
COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/gunicorn

Rebuild clean:

docker build --no-cache -t student-api:1.0.0 .

3. Container Conflict When Rebuilding

Error:

Error response from daemon: conflict: unable to delete student-api:1.0.0 ... container 25ae709a77aa is using it

Fix:

# List all containers (even stopped)
docker ps -a

# Remove the stopped container
docker rm 25ae709a77aa

# Remove the image
docker rmi student-api:1.0.0

4. Git Ignoring Dockerfile

Error:

The following paths are ignored by one of your .gitignore files: Dockerfile

Fix: Removed Dockerfile* from .gitignore:

.venv/
.env
__pycache__/
*.pyc
students.db
.git/
.gitignore
tests/
docker-compose*
*.dockerignore

Then committed successfully.


5. VS Code Warning About Gunicorn

Error:

Package gunicorn is not installed in the selected environment

Why: I hadn't installed gunicorn in my local .venv/.

Fix:

# Activate virtual environment
source .venv/bin/activate

# Install dependencies locally
pip install -r requirements.txt

What I Learned Overall

These errors taught me to:

  • Rebuild Images: Always rebuild after changing requirements.txt or Dockerfile.
  • Check Logs: docker logs is your best debugging buddy.
  • Clean Up: Remove old containers/images to avoid conflicts.
  • Document Everything: Clear steps save time later.

What’s Next?

Dockerizing my API was a huge win, as it is now portable and production-ready. My next steps are:

  • Pushing student-api:1.0.0 to Docker Hub to share with others.
  • Adding endpoints like GET /students or DELETE.
  • Writing more tests to ensure reliability.

Thanks for reading! If you’re Dockerizing your own project, I hope my mistakes save you some headaches. Drop a comment with your tips or questions, I’d love to hear them ✨