We're going to build a simple To-Do web app with Flask — step by step.
By the end, your app will:
Our approach: We'll build it piece by piece. Try each step yourself first, then look at the solution on the next slide.
todo-app/
├── app.py ← Flask server (Python)
└── templates/
└── index.html ← Web page template (HTML)
Start by creating a new folder for the project and moving into it.
mkdir creates the foldercd moves into it
Tip: Keep this terminal open — all commands from here run inside the todo-app/ folder.
# Create a new folder called "todo-app"
mkdir todo-app
# Move into the folder
cd todo-app
# Confirm you're in the right place
pwd
# Should show: .../todo-app
A virtual environment keeps Flask isolated to this project only — it won't affect anything else on your computer.
# 1. Create the virtual environment
python3 -m venv .venv
# 2. Activate it
source .venv/bin/activate
# Your prompt will now show:
# (.venv) $ ← means it is active
# 1. Create the virtual environment
python -m venv .venv
# 2. Activate it
.venv\Scripts\activate
# Your prompt will now show:
# (.venv) PS C:\... ← means it is active
Remember: Every time you open a new terminal for this project, you need to activate the virtual environment again before running anything.
With the virtual environment active, install Flask:
app.py directly in todo-app/templates/ and inside it create index.html
Why templates/? Flask looks for HTML files specifically in a folder called templates. The name is not optional!
# Install Flask (venv must be active first)
pip install Flask
# Verify it installed correctly
pip show flask
# Create the templates folder
mkdir templates
# Now create two empty files:
# todo-app/app.py
# todo-app/templates/index.html
# Your structure should look like:
# todo-app/
# ├── .venv/ ← auto-created by venv
# ├── app.py ← create this (empty for now)
# └── templates/
# └── index.html ← create this (empty for now)
Open app.py and try to write a Flask app that:
Flask from the flask packageFlask(__name__)/ that returns the text "Hello, To-Do App!"debug=TrueGive it a go — then go to the next slide to see the solution.
app.pyEvery line is explained in the comments. Key things to notice:
Flask(__name__) — __name__ tells Flask where your app file lives so it can find the templates/ folder@app.route('/') — this decorator registers the function as the handler for the URL /debug=True — the server auto-restarts whenever you save a change to app.py# app.py
# Step 1: Import the Flask class
from flask import Flask
# Step 2: Create the application object.
# __name__ tells Flask where to look for templates and files.
app = Flask(__name__)
# Step 3: Define a route.
# When someone visits http://127.0.0.1:5000/
# Flask calls this function and returns its result to the browser.
@app.route('/')
def index():
return "Hello, To-Do App!"
# Step 4: Run the development server.
# debug=True → server restarts automatically when you save changes.
if __name__ == '__main__':
app.run(debug=True)
Save app.py, then run it from your terminal. Flask will print a URL — open it in your browser.
What you should see: A plain white page with the text Hello, To-Do App!
Getting "command not found"? Your virtual environment is not active. Run source .venv/bin/activate (Mac) or .venv\Scripts\activate (Windows) first.
# Make sure you are in todo-app/ with venv active, then:
python app.py
# Flask prints something like:
# * Running on http://127.0.0.1:5000
# * Debug mode: on
# Open that URL in your browser.
# You should see: Hello, To-Do App!
# To stop the server: Ctrl + C
app.pyInstead of returning a plain string, let's store tasks in a Python list right below the app line. This list acts as our simple in-memory "database."
In-memory means: the list is stored in RAM while the server runs. If you stop and restart the server, it resets to these two starter items. A real app would use a database to save data permanently.
# app.py
from flask import Flask
app = Flask(__name__)
# Our in-memory to-do list.
# Pre-loaded with two starter tasks so the page isn't empty.
todos = ["Learn Flask", "Build a To-Do App"]
@app.route('/')
def index():
# We'll update this soon to show the list on a proper page
return "Hello, To-Do App!"
if __name__ == '__main__':
app.run(debug=True)
Before we create our template, let's look at HTML. HTML stands for HyperText Markup Language — the language that describes the structure of every web page.
Think of it as the skeleton of a website. It uses tags to mark up content:
<p> (open) and </p> (close)Analogy: HTML is like Word headings and bullet points — it gives content meaning and structure.
Here are the tags that appear in our to-do app:
<h1> — main heading (big, bold text)<ul> — unordered list (a bulleted list container)<li> — list item, goes inside <ul><form> — container for user input<input> — a text box<button> — a clickable button<!-- A heading followed by a bulleted list -->
<h1>My Shopping List</h1>
<ul>
<li>Milk</li>
<li>Bread</li>
<li>Cheese</li>
</ul>
<!-- This renders as a bulleted list in the browser -->
A <form> lets users send data to the server. The key attributes:
action="/add" — which URL receives the data when submittedmethod="post" — sends data in the request body (not visible in the URL)name="username" on <input> — the key our Python code uses to read the value
The name attribute on the input is critical — without it, Python can't find what the user typed.
<!-- A "Leave a Review" form -->
<form action="/submit-review" method="post">
<!-- Text input → Python: request.form.get('username') -->
<label>Your name:</label>
<input type="text" name="username" required>
<!-- Dropdown → Python: request.form.get('rating') -->
<label>Rating:</label>
<select name="rating">
<option value="5">⭐⭐⭐⭐⭐ Excellent</option>
<option value="3">⭐⭐⭐ Average</option>
<option value="1">⭐ Poor</option>
</select>
<!-- Textarea → Python: request.form.get('comment') -->
<label>Comment:</label>
<textarea name="comment" rows="3"></textarea>
<button type="submit">Submit Review</button>
</form>
Flask uses Jinja2 — a templating engine — to make HTML dynamic.
It lets us embed Python-like instructions directly inside HTML so we can:
for loops to display a list of itemsif/else to show or hide parts of the pageOne HTML file becomes a "template" that works with many different data values — we just change what we pass in from Python.
Analogy: A mail-merge letter — same template, different names filled in each time.
Two special delimiters to remember:
{{ ... }} — Output a value
Prints the value of a variable onto the page. Like a placeholder that gets replaced.
{% ... %} — Control flow
Used for loops and if statements. Does not print anything itself.
Important: Every {% for %} must be closed with {% endfor %}.
<!-- {{ }} prints the value of the variable 'name' -->
<h1>Hello, {{ name }}!</h1>
<!-- {% %} controls the loop — this line itself prints nothing -->
<ul>
{% for item in item_list %}
<!-- {{ item }} prints each item from the list -->
<li>{{ item }}</li>
{% endfor %}
</ul>
index.html
Open templates/index.html and write an HTML page that:
<h1> heading saying "My To-Do List"<ul> that loops through todos using Jinja2<li>
Hint: use {% for todo in todos %} and {{ todo }}
Give it a go — solution is on the next slide.
index.html — The ListThe {% for %} loop runs once per item in todos. For each item it creates one <li>. This is the most common Jinja2 pattern you will use:
{% for item in list %}{% endfor %}
Note: We use todos in the template because that's the name we'll pass from Python: render_template('index.html', todos=todos)
<!-- templates/index.html -->
<!DOCTYPE html>
<html>
<head>
<title>My To-Do List</title>
</head>
<body>
<h1>My To-Do List</h1>
<ul>
<!-- Loop over every item in the 'todos' list -->
{% for todo in todos %}
<!-- {{ todo }} prints the current item's text -->
<li>{{ todo }}</li>
{% endfor %}
</ul>
</body>
</html>
render_template
Update app.py so the index() function renders your HTML template instead of returning a plain string.
render_template to the existing import linereturn "Hello..." with return render_template('index.html', todos=todos)The second argument todos=todos passes our Python list into the template.
render_template in app.pyrender_template('index.html', todos=todos) does two things:
templates/index.html and processes all Jinja2 tagstodos list so the template can access itAfter saving, refresh your browser. You should see the two starter tasks listed on the page!
# app.py
# Add render_template to the import line
from flask import Flask, render_template
app = Flask(__name__)
todos = ["Learn Flask", "Build a To-Do App"]
@app.route('/')
def index():
# render_template finds templates/index.html,
# fills in the Jinja2 placeholders, and returns finished HTML.
#
# todos=todos:
# left side → the variable name the template uses: {{ todos }}
# right side → the Python list we defined above
return render_template('index.html', todos=todos)
if __name__ == '__main__':
app.run(debug=True)
Below the list in index.html, add a form that lets users type in a new task.
method="post" and sends to action="/add"name="todo_item"The name="todo_item" is how our Python code will read what the user typed.
index.htmlWhen the user clicks "Add Task", the browser sends a POST request to /add with the text they typed.
The name="todo_item" attribute on the input is how Python retrieves the value: request.form.get('todo_item')
<!-- templates/index.html -->
<h1>My To-Do List</h1>
<ul>
{% for todo in todos %}
<li>{{ todo }}</li>
{% endfor %}
</ul>
<hr>
<h2>Add a new task</h2>
<!-- action="/add" → sends the data to the /add route in app.py -->
<!-- method="post" → data goes in the request body, not the URL -->
<form action="/add" method="post">
<!-- name="todo_item" → Python reads this with request.form.get('todo_item') -->
<input type="text" name="todo_item" placeholder="Enter a task..." required>
<button type="submit">Add Task</button>
</form>
/add in app.py
Add a new route to app.py that handles the form submission.
request and redirect to the import line@app.route('/add', methods=['POST'])request.form.get('todo_item')todos, then redirect('/')/add RouteWhy do we redirect instead of rendering the template directly?
If we rendered the template on a POST, refreshing the browser would re-submit the form and add the same task again. Redirecting to / makes the browser do a fresh GET request instead.
This pattern is called Post/Redirect/Get and is a standard best practice in web development.
# app.py
# Add request and redirect to the import line
from flask import Flask, render_template, request, redirect
app = Flask(__name__)
todos = ["Learn Flask", "Build a To-Do App"]
@app.route('/')
def index():
return render_template('index.html', todos=todos)
@app.route('/add', methods=['POST'])
def add():
# Read the value typed in the form field named 'todo_item'
new_todo = request.form.get('todo_item')
# Only append if the user actually typed something
if new_todo:
todos.append(new_todo)
# Redirect the browser back to the homepage (GET /)
# This prevents the same item being added again on page refresh
return redirect('/')
if __name__ == '__main__':
app.run(debug=True)
Run the server and try these four things to confirm everything works:
Ctrl+C), restart it, and notice that any added tasks are gone — the in-memory list has resetCongratulations! You built a working web app with Python and Flask. Next up: Docker — a way to package and ship apps like this anywhere in the world.
# Run from inside todo-app/ with venv active
python app.py
# Open in your browser:
# http://127.0.0.1:5000/
# Test checklist:
# ✓ Page loads with two starter todos
# ✓ Add a task via the form → appears in list
# ✓ No page reload needed — redirect handles it
# ✓ Restart server → manually added tasks are gone (in-memory)
Docker is an open-source platform for the containerization of applications.
It solves the classic problem: "It works on my machine, but not on the server."
Think of it like a real-world shipping container. Before them, shipping was a mess. You had different-sized boxes, barrels, and sacks. Now, everything goes into a standard container that can be moved by any crane, ship, or truck in the world.
A Docker container does the same thing for software. It packages everything an application needs into a single, standard unit that runs the same way everywhere.
A container includes everything required to run your application in a predictable way:
The Problem (Without Docker):
Python 3.9 and Flask 2.0. It works perfectly.Python 3.7 and an older version of Flask installed for another project. When you deploy your code, it crashes due to version conflicts.The Solution (With Docker):
Python 3.9, and Flask 2.0 into a Docker container.There are two primary ways to install Docker:
For this course, we will primarily use Docker Desktop and the command line.
The three main components you'll interact with in Docker are:
Docker Hub is a registry for Docker images.
The `docker pull` command downloads an image from a registry (Docker Hub by default) to your local machine.
Let's pull a simple "hello-world" image.
# Pull the 'hello-world' image from Docker Hub
docker pull hello-world
The `docker run` command creates and starts a new container from a specified image.
When you run `hello-world`, it creates a container, prints a message, and then exits.
The `--rm` flag is useful as it automatically removes the container after it exits.
# Run a container from the hello-world image
docker run hello-world
# Run and automatically remove the container on exit
docker run --rm hello-world
If you try to `docker run` an image that you don't have locally, Docker will automatically try to `pull` it from Docker Hub first.
You can also specify a command to execute inside the container.
# If the python image is not local, Docker will pull it first.
# Then it runs the container and executes the python command inside it.
docker run --rm python:3.12-slim python -c "print('Hello from a container!')"
Tags:
Layers:
You can list your local images and running containers from the command line.
docker image ls shows all images on your machine.
docker ps shows only the currently running containers.
To see all containers, including stopped ones, use the `-a` flag.
# List all local images
docker image ls
# List running containers
docker ps
# List all containers (running and stopped)
docker ps -a
You can interact with containers using their ID or auto-generated name.
docker logs shows the log output of a container.
docker stop gracefully stops a running container.
# First, find the container ID with docker ps
docker ps
# View logs for a specific container
docker logs <container_id_or_name>
# Stop a running container
docker stop <container_id_or_name>
Over time, you can accumulate many stopped containers.
The `docker container prune` command is a convenient way to remove all stopped containers at once.
It will ask for confirmation before deleting.
# Remove all stopped containers
docker container prune
You can get an interactive shell inside a running container using `docker exec`.
This is extremely useful for debugging and inspecting the container's environment.
The `-it` flags stand for interactive and TTY (which allocates a pseudo-terminal).
# Start a container in the background (-d for detached)
docker run -d --name my-busybox busybox sleep 3600
# Execute a shell command inside the running container
docker exec -it my-busybox sh
By default, any data created inside a container's filesystem is tied to the life of that container.
If you create a file, then stop and remove the container, that file is gone forever.
How can we save data permanently, like in a database?
Solution: Docker Volumes.
Volumes are managed by Docker and exist outside the container's lifecycle.
You can create a volume and then "mount" it to a specific path inside one or more containers.
The `-v` flag maps `volume-name` to `/path/in/container`.
# 1. Create a named volume
docker volume create my-data-volume
# 2. Run a container and mount the volume
# Maps 'my-data-volume' to the '/data' directory inside the container
docker run -d --name my-container -v my-data-volume:/data busybox sleep 3600
So far, we've only used pre-built images from Docker Hub.
The real power of Docker comes from packaging your own applications.
To do this, we create a special file called a `Dockerfile`.
A `Dockerfile` is a text file that contains all the commands, in order, needed to build a given image.
Let's look at some core instructions for a Python Flask app.
# Use an official Python runtime as a parent image
FROM python:3.12-slim
# Set the working directory in the container to /app
WORKDIR /app
# Copy the requirements file into the container at /app
COPY requirements.txt .
# Install any needed packages specified in requirements.txt
RUN pip install --no-cache-dir -r requirements.txt
# Copy the rest of the application code
COPY . .
# Make port 5000 available to the world outside this container
EXPOSE 5000
# Define the command to run the app
CMD ["python3", "app.py"]
Once your `Dockerfile` is ready, you use the `docker build` command to create the image.
# Build the image from the Dockerfile in the current directory
docker build -t flask-app:latest .
To run our custom-built image, we use `docker run` as before.
The crucial addition is the `-p` (publish) flag. It maps a port from your host machine to a port inside the container.
Syntax: `-p <host_port>:<container_port>`.
Now you can access `http://localhost:5000` in your browser to see the app.
# Run the container, mapping port 5000 on the host
# to port 5000 inside the container.
docker run --rm -p 5000:5000 flask-app:latest