Build and Deploy a Medication Reminder API

Build a Python Flask API, containerize it, and deploy to Kubernetes with CI/CD.

Introduction

30 Second Summary

Millions of people rely on medication reminders to stay healthy. Behind every reminder app is an API that stores, retrieves, and manages those schedules.

In this project, you will build a medication reminder API from scratch using Python and Flask. You will then package it in a Docker container, deploy it to a Kubernetes cluster, and automate the entire process with a CI/CD pipeline.

What You'll Build

A REST API with endpoints to add, list, and delete medication reminders, running on a local Kubernetes cluster with automated builds and deployments.

By the end of this project, you'll have:

  • A Python Flask API written from scratch with health check and CRUD endpoints.
  • A Docker container that packages the app into a portable, deployable image.
  • A Kubernetes deployment with health probes that automatically restarts the app if it crashes.
  • A GitHub Actions CI/CD pipeline that builds, pushes, and deploys on every push.
  • Secret Mission: Add a PUT endpoint to update reminders and trigger a rolling update with zero downtime.

Are there any prerequisites?

You should have basic comfort with the command line (navigating folders, running commands). Familiarity with Git (cloning repos, pushing code) is also helpful.

Install Your Tools

Before writing any code, you need the right tools on your machine. This project uses five tools that work together to take your API from source code to a running cluster.

You'll install VS Code for editing code, Python for writing the API, Docker for packaging it into a container, and Minikube with kubectl for running a local Kubernetes cluster.

In this step, get ready to:
  • Install VS Code and Python 3.12.
  • Install Docker Desktop with the WSL 2 backend.
  • Install Minikube and kubectl for local Kubernetes.
Install VS Code, Python 3.12, and pip

VS Code is the code editor you'll use to write your API. Python is the programming language, and pip is Python's package installer for adding libraries like Flask.

  • Download and install VS Code from the official site.
  • Download and install Python 3.12 from python.org.
  • During the Python installation, check the box that says Add python.exe to PATH.

Why add Python to PATH?

Adding Python to your PATH lets you run python and pip commands from any terminal window. Without this, your computer won't know where to find Python.

Once Python is installed, open a new terminal to verify it works.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Verify Python is installed by running this command:
python --version

✔️ I see a version number

You should see something like Python 3.12.x. Python is ready to go.

ⓧ Command not found

If you see an error, Python isn't on your PATH yet.

  • Close your terminal completely and reopen it (PATH changes require a fresh terminal).
  • If it still doesn't work, re-run the Python installer and make sure Add python.exe to PATH is checked.
  • Verify pip is installed by running:
pip --version

✔️ I see a version number

You should see a version number and a file path. pip comes bundled with Python, so this confirms your Python installation is complete.

ⓧ Command not found

If pip isn't found, try running pip3 --version instead. On some systems, the command is pip3.

If neither works, re-install Python and ensure you check the PATH option during setup.

Install Docker Desktop

Docker packages your application into a container, which is a lightweight, portable unit that runs the same way everywhere. You'll use it to package your Flask API before deploying it to Kubernetes.

  • Download Docker Desktop from the official site.
  • Run the installer and follow the prompts.
  • During installation (or in Settings > General after install), make sure Use the WSL 2 based engine is checked.
  • Restart your computer if prompted.

What is WSL 2?

WSL 2 (Windows Subsystem for Linux 2) lets Docker run a real Linux kernel on Windows. Docker containers are Linux-based, so WSL 2 gives them a native environment to run in without the overhead of a full virtual machine.

  • Open Docker Desktop and wait for the engine to start (you'll see a green status indicator in the bottom-left corner).
  • Verify Docker is working by running this command in your terminal:
docker --version

✔️ I see a version number

You should see something like Docker version 27.x.x. Docker is ready.

ⓧ Command not found or error

Docker Desktop may not have finished starting.

  • Make sure Docker Desktop is open and running (check for the whale icon in your system tray).
  • Close your terminal and open a fresh one, then try the command again.
  • If you see a WSL error, open PowerShell as Administrator and run:
wsl --install

Restart your computer after WSL installs, then open Docker Desktop again.

Install Minikube and kubectl

Minikube runs a single-node Kubernetes cluster on your local machine. It's the official tool for learning Kubernetes without needing a cloud account.

kubectl is the command-line tool you use to talk to any Kubernetes cluster. Think of Minikube as the cluster itself, and kubectl as the remote control.

System requirements for Minikube

Minikube needs at least 2 CPUs, 2 GB of RAM, and 20 GB of free disk space. It also requires Docker Desktop to be installed (which you just did).

  • Install Minikube by running this command in PowerShell:
winget install Kubernetes.minikube
  • Install kubectl by running:
winget install Kubernetes.kubectl

What is winget?

winget is the Windows Package Manager. It lets you install software from the command line, similar to how brew works on macOS. It comes pre-installed on Windows 10 (version 1809+) and Windows 11.

Close your terminal and open a fresh one so the new PATH entries take effect.

  • Verify Minikube is installed by running:
minikube version

✔️ I see a version number

You should see minikube version: v1.x.x. Minikube is ready.

ⓧ Command not found

  • Make sure you closed and reopened your terminal after running the winget install command.
  • Try running winget install Kubernetes.minikube again and check for any error messages.
  • Verify kubectl is installed by running:
kubectl version --client

✔️ I see a version number

You should see a Client Version line with a version number. kubectl is ready to communicate with your Kubernetes cluster.

ⓧ Command not found

  • Close and reopen your terminal to pick up the new PATH entries.
  • If it still fails, run winget install Kubernetes.kubectl again and watch for errors in the output.

Having trouble with any install?

The most common fix is closing your terminal completely and opening a new one. PATH changes from installers only take effect in new terminal sessions.

If winget itself isn't recognized, make sure you're on Windows 10 version 1809 or later, or Windows 11.

help me troubleshoot my tool installation for this project

All five tools are now installed and verified. Next up, you'll use Python and Flask to write the medication reminder API from scratch.

Write the Flask API: Setup and Health Route

Your tools are all installed and verified. Now it's time to write the actual application.

In this step, you'll create a Python Flask API with a single health check endpoint. This endpoint will later tell Kubernetes whether your app is alive and running.

In this step, get ready to:
  • Create the project folder and install Flask.
  • Write a health check endpoint in Python.
  • Run and test the API locally.
Create the project folder and install Flask

Every project starts with a dedicated folder and its dependencies. Flask is a lightweight Python web framework that makes it easy to build APIs with just a few lines of code.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir med-reminder
cd med-reminder
  • Confirm the folder exists by running:
ls

You should see an empty directory (no output yet). That's expected.

  • Open this folder in VS Code by running:
code .

code command not found?

If your terminal doesn't recognize code, open VS Code manually. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, type VS Code, and press Enter. Then use File > Open Folder to open the med-reminder folder on your Desktop.

help me set up the code command in my terminal for VS Code

VS Code opens with your empty med-reminder folder in the Explorer sidebar.

  • In VS Code's Explorer sidebar on the left, hover over the folder name and click the new file icon (a page with a plus sign).
  • Name the file requirements.txt and press Enter.
  • Add this line to requirements.txt:
flask==3.1.3

What is requirements.txt?

This file lists all the Python packages your project depends on, along with their exact versions. When someone else clones your project, they can run one command to install everything.

Pinning Flask to version 3.1.3 ensures your app behaves the same way on every machine and inside every container.

  • Save requirements.txt with Ctrl+S (Windows) or Cmd+S (macOS).
  • Open a terminal inside VS Code by pressing Ctrl+` (backtick key, top-left of your keyboard).
  • Install Flask by running this command:
pip install -r requirements.txt

You should see output ending with Successfully installed flask-3.1.3 (plus a few other packages Flask depends on).

pip command not found?

Try running pip3 install -r requirements.txt instead. Some systems use pip3 for Python 3 packages.

Also make sure your VS Code terminal opened in the med-reminder folder. Check the terminal prompt to confirm.

help me fix pip not being recognized in my terminal

Write the app setup and storage

Your Flask app needs a few things before it can serve requests: the Flask library imported, an application instance created, and a place to store reminder data in memory.

  • In VS Code's Explorer sidebar, click the new file icon and create a file called app.py.
  • Add the following code to app.py:
from flask import Flask, request

# Create the Flask application instance
app = Flask(__name__)

# In-memory storage for medication reminders
reminders = []
next_id = 1

What does this code do?

from flask import Flask, request brings in two tools from the Flask library. Flask is the class that creates your web application. request lets you read incoming data from users (you'll use it in the next step).

app = Flask(__name__) creates the actual application object. The __name__ argument tells Flask where to find your project files.

reminders is an empty Python list that will hold all medication reminders. next_id is a counter that gives each new reminder a unique number.

  • Save app.py.

You won't see a change yet. The next chunk adds the route that makes the app respond to requests.

Add the health route and app runner

A health endpoint is a URL that returns a simple status message. Kubernetes uses this endpoint to check whether your app is still alive and restart it if it crashes.

  • In app.py, add the following code below the next_id = 1 line:

@app.route("/health")
def health():
    return {"status": "healthy"}


# Start the server when this file is run directly
if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

What does this code do?

@app.route("/health") is a Python decorator. It tells Flask that whenever someone visits /health, run the function directly below it.

The health() function returns a Python dictionary. Flask automatically converts this dictionary into a JSON response that any web client can understand.

The if __name__ == "__main__" block runs the server only when you execute the file directly with python app.py. The host="0.0.0.0" setting makes the app accessible from outside its container, which is required for Docker later.

  • Save app.py.

Why Flask over FastAPI?

Flask lets you return plain dictionaries and lists as JSON without any extra imports or type annotations. This keeps the code simple while you're learning Python basics like functions, decorators, and return values.

Seeing red underlines in VS Code?

If VS Code shows an error on the from flask import line, it might not be using the Python environment where you installed Flask. Check the bottom-left of VS Code for the Python version. Click it and select the interpreter that matches where pip installed Flask.

help me fix Flask import errors in VS Code

Run and test the API

Your Flask app is ready to serve requests. Running it starts a local web server that listens for incoming connections on port 5000.

  • In your VS Code terminal, start the Flask server by running:
python app.py

You should see output like * Running on http://0.0.0.0:5000 in your terminal. The server is now running and waiting for requests.

  • Open your web browser and navigate to http://localhost:5000/health.

You should see {"status":"healthy"} displayed in the browser. That JSON response confirms your Flask API is working.

Seeing "This site can't be reached"?

Make sure the server is still running in your terminal. If you see an error instead of the "Running on" message, check for typos in app.py. A missing colon after def health() or a misspelled decorator are the most common causes.

Also confirm you're using http:// in the URL, not https://. The dev server does not support HTTPS.

help me troubleshoot my Flask server not starting

  • Back in your terminal, press Ctrl+C to stop the Flask server.

✔️ Awesome, I've got everything!

Great. Double check you've saved both requirements.txt and app.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete app.py for this step:

from flask import Flask, request

app = Flask(__name__)

reminders = []
next_id = 1


@app.route("/health")
def health():
    return {"status": "healthy"}


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

And the complete requirements.txt:

flask==3.1.3

Your API has a heartbeat. Next up, you'll add the real functionality: endpoints to create, list, and delete medication reminders.

Write the Flask API: CRUD Endpoints

Your Flask app is alive and responding to health checks. Now it's time to build the actual medication reminder functionality.

Every REST API needs a way to create, read, and delete data. In this step, you'll write three new routes that let users add medication reminders, view all reminders, and remove them by ID.

In this step, get ready to:
  • Write a POST route that creates new medication reminders.
  • Write GET and DELETE routes to list and remove reminders.
  • Test all three endpoints with curl commands.
Write the POST route to create reminders

The POST route is how users will add new medication reminders to the system. It reads JSON data from the request body, builds a reminder with a unique ID, and stores it in memory.

  • In app.py, find the blank line above if __name__ == "__main__": and add the following code there:
# Add a new medication reminder
@app.route("/reminders", methods=["POST"])
def add_reminder():
    global next_id
    # Parse the JSON body from the incoming request
    data = request.get_json()
    # Build a reminder dictionary with a unique ID
    reminder = {
        "id": next_id,
        "medication": data["medication"],
        "dosage": data["dosage"],
        "schedule": data["schedule"],
    }
    # Store the reminder and increment the ID counter
    reminders.append(reminder)
    next_id += 1
    # Return the new reminder with a 201 Created status
    return reminder, 201

What does this code do?

global next_id tells Python this function will modify the next_id variable that lives outside the function. Without it, Python would create a new local variable instead of updating the shared counter.

request.get_json() reads the JSON body sent with the request and converts it into a Python dictionary. You then pull out the medication, dosage, and schedule fields to build the reminder.

Returning a tuple reminder, 201 sends the reminder back as JSON with HTTP status 201 (Created). Flask automatically converts dictionaries into JSON responses.

  • Save app.py.

You won't see a change yet. The next chunk adds the remaining routes, then you'll test everything together.

Write the GET and DELETE routes

Users need to see all their reminders and remove ones they no longer need. The GET route returns the full list, and the DELETE route removes a specific reminder by its ID.

  • In app.py, add the following code directly above the POST route you just wrote (between the health() function and the add_reminder() function):
# Return all stored reminders as a JSON array
@app.route("/reminders", methods=["GET"])
def get_reminders():
    return reminders
  • Now add the DELETE route below your add_reminder() function (still above if __name__ == "__main__":):
# Delete a reminder by its ID
@app.route("/reminders/<int:id>", methods=["DELETE"])
def delete_reminder(id):
    global reminders
    original_length = len(reminders)
    # Keep only reminders whose ID does NOT match
    reminders = [r for r in reminders if r["id"] != id]
    # If the list length didn't change, the ID wasn't found
    if len(reminders) == original_length:
        return {"error": "Reminder not found"}, 404
    return {"message": "Reminder deleted"}, 200

What does this code do?

The GET route simply returns the reminders list. Flask automatically converts a Python list into a JSON array response.

<int:id> in the DELETE route is a URL parameter. Flask extracts the number from the URL and passes it to your function as the id argument.

The list comprehension [r for r in reminders if r["id"] != id] rebuilds the list without the matching reminder. If the list length stays the same, nothing was removed, so you return a 404 error.

  • Save app.py.
  • In the terminal where Flask is running, press Ctrl+C to stop the server.
  • Restart the server by running:
python app.py

You should see Running on http://0.0.0.0:5000 in the terminal output, confirming the server is back up with your new routes.

Seeing a SyntaxError?

Check that every opening brace { has a matching closing brace } in the reminder dictionary. Missing commas between dictionary keys are a common culprit too.

Make sure each function is separated by two blank lines, and that if __name__ == "__main__": is still at the very bottom of the file.

Help me fix a SyntaxError in my Flask app

✔️ Awesome, I've got everything!

Great. Double check you've saved app.py and that the server restarted without errors.

ⓧ I'd like to double check the full code

Here is the complete app.py file at this point. Compare it line by line with your own.

from flask import Flask, request

app = Flask(__name__)

reminders = []
next_id = 1


@app.route("/health")
def health():
    return {"status": "healthy"}


@app.route("/reminders", methods=["GET"])
def get_reminders():
    return reminders


@app.route("/reminders", methods=["POST"])
def add_reminder():
    global next_id
    data = request.get_json()
    reminder = {
        "id": next_id,
        "medication": data["medication"],
        "dosage": data["dosage"],
        "schedule": data["schedule"],
    }
    reminders.append(reminder)
    next_id += 1
    return reminder, 201


@app.route("/reminders/<int:id>", methods=["DELETE"])
def delete_reminder(id):
    global reminders
    original_length = len(reminders)
    reminders = [r for r in reminders if r["id"] != id]
    if len(reminders) == original_length:
        return {"error": "Reminder not found"}, 404
    return {"message": "Reminder deleted"}, 200


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)
Test your endpoints with curl

curl is a command-line tool that sends HTTP requests. You'll use it to simulate what a real application or frontend would do when talking to your API.

  • Open a second terminal window (keep the Flask server running in the first one).
  • Create a new reminder by running this curl command:
curl -X POST http://localhost:5000/reminders -H "Content-Type: application/json" -d "{\"medication\": \"Aspirin\", \"dosage\": \"100mg\", \"schedule\": \"8:00 AM\"}"

You should see a JSON response with your new reminder, including an id of 1:

{"dosage":"100mg","id":1,"medication":"Aspirin","schedule":"8:00 AM"}
  • List all reminders by running:
curl http://localhost:5000/reminders

You should see a JSON array containing the Aspirin reminder you just created.

  • Delete the reminder by running:
curl -X DELETE http://localhost:5000/reminders/1

You should see {"message":"Reminder deleted"} confirming the reminder was removed.

  • Verify the list is empty by running the GET command again:
curl http://localhost:5000/reminders

You should see an empty array [] confirming the delete worked.

Getting Connection refused?

Make sure your Flask server is still running in the other terminal window. If you accidentally closed it, restart it with python app.py.

If you see a 405 Method Not Allowed error, double check that your route decorators include the correct methods=["POST"] or methods=["DELETE"] parameter.

Help me debug my curl commands against my Flask API

Why does data reset when I restart the server?

Your reminders are stored in a Python list that lives in memory. Every time you stop and restart the server, that list starts fresh as an empty []. This is intentional for learning purposes. A production app would use a database to persist data across restarts.

Your API can now create, list, and delete medication reminders. Next up, you'll package this app into a Docker container so it runs the same way on any machine.

Containerize with Docker

Your API works perfectly on your machine. But what happens when you want to run it somewhere else, like a server or a colleague's laptop?

This is where Docker comes in. Docker packages your app, its dependencies, and its runtime environment into a single portable unit called a container. In this step, you'll write a Dockerfile, build an image, and run your API inside a container.

In this step, get ready to:
  • Create a Dockerfile that packages your Flask API.
  • Build a Docker image and run it as a container.
  • Test your API running inside the container.
Create the Dockerfile

A Dockerfile is a recipe that tells Docker exactly how to build your app's environment. It specifies the base operating system, installs dependencies, copies your code, and defines how to start the app.

  • Create a new file called Dockerfile in your med-reminder project folder (the same folder that contains app.py).
  • Paste the following code into Dockerfile:
FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

EXPOSE 5000

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

What does this code do?

  • FROM python:3.12-slim starts from a lightweight Linux image that already has Python 3.12 installed.
  • WORKDIR /app creates a folder inside the container and makes it the active directory for all following commands.
  • COPY requirements.txt . and RUN pip install install your dependencies first. This is separate from copying app.py so Docker can cache the install step when only your code changes.
  • EXPOSE 5000 documents which port the app listens on.
  • CMD ["python", "app.py"] tells Docker what command to run when the container starts.
  • Save Dockerfile.
  • Confirm the file exists by running:
ls

You should see Dockerfile, app.py, and requirements.txt in the output.

Don't see the Dockerfile?

Make sure the file is named exactly Dockerfile with a capital D and no file extension. It should not be Dockerfile.txt or dockerfile.

Also confirm your terminal is inside the med-reminder folder where you created the file.

Why can't Docker find my Dockerfile?

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

FROM python:3.12-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY app.py .

EXPOSE 5000

CMD ["python", "app.py"]
Build and run the Docker container

Now that your recipe is ready, you can build it into a Docker image. An image is a snapshot of your app and everything it needs to run.

  • Build your Docker image by running this command:
docker build -t med-reminder:v1 .

What does this command do?

  • docker build reads your Dockerfile and assembles the image layer by layer.
  • -t med-reminder:v1 tags (names) the image as med-reminder with version v1.
  • The . at the end tells Docker to look for the Dockerfile in the current directory.

You should see Docker downloading the Python base image and installing Flask. The last line shows Successfully tagged med-reminder:v1.

Seeing a build error?

Make sure Docker Desktop is running. You can check by looking for the Docker whale icon in your system tray.

If you see COPY failed: file not found, confirm that requirements.txt and app.py are in the same folder as your Dockerfile.

Why is my Docker build failing?

  • Run your container by executing this command:
docker run -p 5000:5000 med-reminder:v1

What does -p 5000:5000 mean?

The -p 5000:5000 flag maps port 5000 on your machine to port 5000 inside the container. Without this, your container's API would be unreachable from your browser.

You should see Flask start up in your terminal, just like when you ran it directly.

  • Open your browser and go to http://localhost:5000/health.

You should see {"status":"healthy"} in the browser. Your API is now running inside a Docker container.

Browser showing an error?

Check your terminal where docker run is running. If you see a Python error, there may be a typo in app.py. Fix the error, rebuild with docker build -t med-reminder:v1 ., and run again.

If the terminal shows Flask running but the browser cannot connect, confirm you included -p 5000:5000 in your docker run command.

My Flask container is running but I can't reach it in the browser.

Stop the container

Your container is running in the foreground (occupying your terminal). To stop it, press Ctrl+C in the terminal where it is running.

  • Press Ctrl+C in the terminal running your container.
  • Verify the container has stopped by running:
docker ps

You should see an empty table with just the column headers. No containers are running.

What is the difference between an image and a container?

An image is a read-only blueprint. It contains your code, dependencies, and OS layers. You built one with docker build.

A container is a running instance of that image. You created one with docker run. You can start many containers from the same image, just like printing multiple copies from one template.

Stopping background containers

If you ever run a container in the background (with the -d flag), use docker ps to find its container ID, then docker stop <container-id> to shut it down.

Your API is now containerized and portable. Next up, you'll deploy this container to a Kubernetes cluster so it can run with multiple replicas and automatic health checks.

Deploy to Kubernetes with Minikube

Your API is packaged inside a Docker container. However, a single container running on your laptop is not how real apps work in production.

In production, you need multiple copies of your app running at once so that if one crashes, another takes over. Kubernetes is the orchestrator that manages all of this for you. In this step, you will deploy your containerized API to a local Kubernetes cluster using Minikube.

In this step, get ready to:
  • Start a local Kubernetes cluster with Minikube.
  • Write Kubernetes manifests to define your app's deployment and networking.
  • Access your API running on Kubernetes through a browser.
Start Minikube and load your Docker image

Minikube creates a single-node Kubernetes cluster on your machine. It runs inside Docker, so it uses the same Docker Desktop you already installed.

  • Start your Minikube cluster by running this command:
minikube start --driver=docker

This takes a minute or two. You should see progress messages ending with a line that confirms the cluster is ready.

Seeing an error about Docker not running?

Make sure Docker Desktop is open and fully started before running this command. You can check by looking for the Docker whale icon in your system tray.

Minikube also requires at least 2 CPUs and 2GB of RAM available. Close other heavy applications if you see resource errors.

help me troubleshoot Minikube start errors with Docker driver on Windows

  • Verify the cluster is running by checking the node status:
kubectl get nodes

You should see one node with a status of Ready.

What is a node?

A node is a machine (physical or virtual) in a Kubernetes cluster. Minikube gives you one node. In production, clusters have many nodes spread across data centers.

Minikube runs its own internal Docker environment, separate from your local Docker. Your med-reminder:v1 image exists locally but Minikube cannot see it yet.

  • Load your Docker image into Minikube by running:
minikube image load med-reminder:v1

This copies the image from your local Docker into Minikube's internal registry. It may take 10-20 seconds.

Image load taking forever or failing?

Make sure you built the image in Step 4 with the exact tag med-reminder:v1. You can verify it exists locally by running docker images med-reminder.

help me debug minikube image load failing

Create the Kubernetes Deployment manifest

Kubernetes uses YAML files called manifests to describe what you want running in your cluster. A Deployment manifest tells Kubernetes how many copies of your app to run and how to keep them healthy.

  • Create a k8s directory inside your med-reminder project folder by running:
mkdir k8s
  • Confirm the directory was created by running:
ls

You should see k8s listed alongside app.py, Dockerfile, and requirements.txt.

  • In VS Code, create a new file called deployment.yaml inside the k8s folder.
  • Copy the following code into k8s/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
  name: med-reminder
  labels:
    app: med-reminder
spec:
  replicas: 2
  selector:
    matchLabels:
      app: med-reminder
  template:
    metadata:
      labels:
        app: med-reminder
    spec:
      containers:
        - name: med-reminder
          image: med-reminder:v1
          imagePullPolicy: Never
          ports:
            - containerPort: 5000
          livenessProbe:
            httpGet:
              path: /health
              port: 5000
            initialDelaySeconds: 10
            periodSeconds: 5
          readinessProbe:
            httpGet:
              path: /health
              port: 5000
            initialDelaySeconds: 5
            periodSeconds: 3

What does this manifest do?

  • replicas: 2 tells Kubernetes to run two copies of your app. If one crashes, the other keeps serving requests.
  • imagePullPolicy: Never tells Kubernetes to use the image you loaded locally instead of trying to download it from the internet.
  • livenessProbe checks /health every 5 seconds. If the app stops responding, Kubernetes automatically restarts the container.
  • readinessProbe checks /health every 3 seconds. Kubernetes only sends traffic to containers that pass this check.

Why imagePullPolicy: Never?

By default, Kubernetes tries to pull images from a remote registry like Docker Hub. Since your image only exists inside Minikube (you loaded it with minikube image load), you need to tell Kubernetes not to pull from the internet. Without this setting, your pods will fail with an ImagePullBackOff error.

  • Save k8s/deployment.yaml.

YAML indentation errors later?

YAML is very sensitive to indentation. Every level uses exactly 2 spaces. Tabs will cause errors. Make sure VS Code is set to use spaces (check the bottom status bar for "Spaces: 2").

help me fix YAML indentation errors in my Kubernetes deployment manifest

Create the Service manifest and deploy

Your Deployment tells Kubernetes to run your app, but it does not expose it to the outside world. A Service is the networking layer that makes your app accessible.

  • Create a new file called service.yaml inside the k8s folder.
  • Copy the following code into k8s/service.yaml:
apiVersion: v1
kind: Service
metadata:
  name: med-reminder-service
spec:
  type: NodePort
  selector:
    app: med-reminder
  ports:
    - port: 80
      targetPort: 5000

What does this Service do?

  • type: NodePort exposes the Service on a port that is accessible from outside the cluster.
  • selector: app: med-reminder routes traffic to pods that have the label app: med-reminder (the ones your Deployment creates).
  • port: 80 is what the Service listens on. targetPort: 5000 is the port your Flask app runs on inside the container.
  • Save k8s/service.yaml.
  • Apply both manifests to your cluster by running:
kubectl apply -f k8s/

You should see two lines confirming the Deployment and Service were created:

deployment.apps/med-reminder created
service/med-reminder-service created
  • Check that your pods are running by executing:
kubectl get pods

You should see 2 pods with a status of Running. It may take 15-30 seconds for them to fully start.

Pods stuck in ImagePullBackOff or ErrImagePull?

This means Kubernetes is trying to pull the image from the internet instead of using the local copy. Double check that your deployment.yaml has imagePullPolicy: Never set on the container spec.

Also confirm you ran minikube image load med-reminder:v1 earlier in this step. You can verify the image is inside Minikube by running minikube image list.

help me fix ImagePullBackOff errors in Minikube

  • Get the URL to access your app by running:
minikube service med-reminder-service --url

This prints a URL like http://127.0.0.1:xxxxx. Copy it.

  • Open that URL in your browser and append /health to the end.

You should see {"status": "healthy"} in your browser. Your API is now running on Kubernetes with two replicas and automatic health monitoring.

Can't reach the service URL?

On Windows, the minikube service command sometimes opens a tunnel that stays active only while the terminal window is open. Keep your terminal open and try the URL again.

If the URL shows nothing, check that your pods are in Running status with kubectl get pods. If they show CrashLoopBackOff, the app is crashing. Check the logs with kubectl logs <pod-name>.

help me access my app running on Minikube

✔️ Awesome, I've got everything!

Great. Double check you have saved both files in your k8s folder.

ⓧ I'd like to double check the full code

Here is the full k8s/deployment.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: med-reminder
  labels:
    app: med-reminder
spec:
  replicas: 2
  selector:
    matchLabels:
      app: med-reminder
  template:
    metadata:
      labels:
        app: med-reminder
    spec:
      containers:
        - name: med-reminder
          image: med-reminder:v1
          imagePullPolicy: Never
          ports:
            - containerPort: 5000
          livenessProbe:
            httpGet:
              path: /health
              port: 5000
            initialDelaySeconds: 10
            periodSeconds: 5
          readinessProbe:
            httpGet:
              path: /health
              port: 5000
            initialDelaySeconds: 5
            periodSeconds: 3

Here is the full k8s/service.yaml:

apiVersion: v1
kind: Service
metadata:
  name: med-reminder-service
spec:
  type: NodePort
  selector:
    app: med-reminder
  ports:
    - port: 80
      targetPort: 5000

Your API is now running on a real Kubernetes cluster with health checks and automatic recovery. Next up, you will automate the entire build and deploy process with a CI/CD pipeline using GitHub Actions.

Automate with GitHub Actions

Right now, every time you change your code, you have to manually rebuild the Docker image, load it into Minikube, and re-apply the Kubernetes manifests. That is three commands just to see a one-line code change go live.

A CI/CD pipeline eliminates this manual work entirely. You will set up GitHub Actions so that every push to your main branch automatically builds the image, deploys to Kubernetes, and verifies the rollout.

In this step, get ready to:
  • Create a GitHub repository and push your project code.
  • Write a GitHub Actions workflow that builds and deploys automatically.
  • Verify the pipeline runs successfully in the Actions tab.
Create a GitHub repository and push your code

Your project needs to live on GitHub so that GitHub Actions can access the code and run the pipeline. You will create a new repository on GitHub and push all your local project files to it.

  • Open your browser and go to github.com.
  • Click your profile icon in the top-right corner, then click Your repositories.
  • Click the green New button.
  • Enter med-reminder as the repository name.
  • Leave Public selected.
  • Do NOT check any of the initialization options (no README, no .gitignore, no license).
  • Click Create repository.

GitHub shows a Quick Setup page with your repository URL. Keep this page open.

  • Back in your terminal, make sure you are inside your med-reminder project folder.
  • Initialize a Git repository and push your code by running these commands:
git init -b main
git add .
git commit -m "Initial commit"
git remote add origin [[REPO_URL="https://github.com/YOUR-USERNAME/med-reminder.git"]]
git push -u origin main

What do these commands do?

  • git init -b main creates a new Git repository with main as the default branch.
  • git add . stages all your project files for the first commit.
  • git remote add origin connects your local repository to the GitHub repository you just created.
  • git push -u origin main uploads your code to GitHub.

What is the remote URL?

Copy the HTTPS URL from the Quick Setup page on GitHub. It looks like https://github.com/YOUR-USERNAME/med-reminder.git but with your actual GitHub username.

  • Refresh your GitHub repository page in the browser.
  • You should see your project files listed: app.py, Dockerfile, requirements.txt, and the k8s/ folder.

Push failed or permission denied?

  • Make sure you copied the correct HTTPS URL from your repository page.
  • If prompted for a password, use a Personal Access Token (not your GitHub password). Generate one at Settings > Developer settings > Personal access tokens on GitHub.
  • If you see fatal: remote origin already exists, run git remote set-url origin followed by the correct URL.

Help me push my code to GitHub.

Write the GitHub Actions workflow

GitHub Actions workflows are YAML files that tell GitHub what to do when certain events happen. Your workflow will trigger on every push to main and run the full build-and-deploy pipeline automatically.

  • Create the workflow directory by running this command:
mkdir -p .github/workflows
  • Confirm the directory was created by running:
ls .github/workflows

You should see the empty workflows directory listed without errors.

  • Create a new file called deploy.yml inside .github/workflows/.
  • Open .github/workflows/deploy.yml in VS Code and paste the following workflow:
name: Build and Deploy to Minikube

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Start Minikube
        uses: medyagh/setup-minikube@latest

      - name: Build image in Minikube
        run: |
          minikube image build -t med-reminder:v1 .

      - name: Deploy to Minikube
        run: |
          kubectl apply -f k8s/
          kubectl rollout status deployment/med-reminder --timeout=60s

      - name: Test deployment
        run: |
          kubectl get pods
          kubectl get service med-reminder-service

What does this workflow do?

  • on: push: branches: [main] triggers the workflow every time you push to the main branch.
  • actions/checkout@v4 pulls your repository code into the runner.
  • medyagh/setup-minikube@latest starts a Minikube cluster inside the GitHub Actions runner.
  • minikube image build builds the Docker image directly inside Minikube's Docker daemon, so no external registry is needed.
  • kubectl rollout status waits until the deployment is fully rolled out before the workflow continues. If it takes longer than 60 seconds, the step fails.

This approach keeps everything free. No Docker Hub account, no container registry fees, and no rate limits.

Why build inside Minikube instead of Docker?

When you build with minikube image build, the image goes directly into Minikube's internal Docker daemon. This skips the push-to-registry-then-pull cycle entirely.

Combined with imagePullPolicy: Never in your Deployment, Kubernetes uses the local image without trying to fetch it from the internet.

  • Save .github/workflows/deploy.yml.

✔️ Awesome, I've got everything!

Great. Make sure you have saved deploy.yml inside the .github/workflows/ directory.

ⓧ I'd like to double check the full code

Here is the complete .github/workflows/deploy.yml file:

name: Build and Deploy to Minikube

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Start Minikube
        uses: medyagh/setup-minikube@latest

      - name: Build image in Minikube
        run: |
          minikube image build -t med-reminder:v1 .

      - name: Deploy to Minikube
        run: |
          kubectl apply -f k8s/
          kubectl rollout status deployment/med-reminder --timeout=60s

      - name: Test deployment
        run: |
          kubectl get pods
          kubectl get service med-reminder-service
Push the workflow and verify the pipeline

Now that your workflow file is ready, pushing it to GitHub will automatically trigger the pipeline. This is the moment where everything comes together.

  • Stage, commit, and push the workflow file by running these commands:
git add .
git commit -m "Add GitHub Actions workflow"
git push origin main

What happens after the push?

The moment your push lands on main, GitHub detects the workflow file and starts running the pipeline. You do not need to click anything to trigger it.

  • In your browser, go to your med-reminder repository on GitHub.
  • Click the Actions tab at the top of the page.
  • You should see a workflow run in progress called Build and Deploy to Minikube.
  • Click on the workflow run to see detailed logs for each step.
  • Wait for all steps to show green checkmarks. This takes about 2-3 minutes.

Once the pipeline finishes, you should see all five steps with green checkmarks: Checkout code, Start Minikube, Build image in Minikube, Deploy to Minikube, and Test deployment.

Pipeline failed?

  • Click the failed step to expand its logs. The error message tells you exactly what went wrong.
  • If the "Build image in Minikube" step fails, check that your Dockerfile is in the project root (not inside a subfolder).
  • If the "Deploy to Minikube" step times out, check that your k8s/deployment.yaml references med-reminder:v1 as the image name and has imagePullPolicy: Never.
  • If you see a YAML syntax error, make sure your indentation uses spaces (not tabs) and matches the code above exactly.

My GitHub Actions pipeline is failing. Help me debug it.

Secret mission

Add a PUT Endpoint and Trigger a Rolling Update

Your API can create, list, and delete reminders. But what if a patient's dosage changes? Add a PUT endpoint to update existing reminders, then deploy the new version to Kubernetes with zero downtime using a rolling update.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with free tools, so there are no ongoing cloud costs.

Resources you used:

  • Minikube cluster (local Kubernetes).
  • Docker image med-reminder:v1.
  • Kubernetes Deployment and Service running inside Minikube.
  • Project folder with app.py, Dockerfile, and k8s/ manifests.
  • GitHub repository with Actions workflow.

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.

  • Your Minikube cluster stays running and accessible.
  • Your Docker image remains available locally.
  • Your GitHub repository continues to trigger Actions on push.

Pause - I'll come back to this later

Shut down running processes to free up memory and CPU. Your files and GitHub repository stay intact.

  • Stop the Minikube cluster by running:
minikube stop
  • Quit Docker Desktop from the system tray to free up memory.
  • When you are ready to resume, open Docker Desktop and run minikube start to pick up where you left off.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is irreversible.

  • Delete the Minikube cluster entirely by running:
minikube delete
  • Remove the Docker image by running:
docker rmi med-reminder:v1
  • Delete your project folder from the Desktop.
  • Optionally, delete the GitHub repository from your GitHub account under Settings > General > Delete this repository.

That's a Wrap!

That's a Wrap!

Nice work! You just built a full medication reminder API from scratch and deployed it to a Kubernetes cluster with automated CI/CD.

You've learned how to:

  • Build a Python Flask REST API from scratch with CRUD endpoints and a health check route.
  • Containerize an application with Docker and deploy it to Kubernetes with liveness and readiness probes that auto-restart unhealthy pods.
  • Automate the entire build-and-deploy pipeline with GitHub Actions so every push to main triggers a fresh deployment.
  • Secret Mission: Added a PUT endpoint for updating reminders and performed a zero-downtime rolling update on Kubernetes.

Ready to quiz yourself?