๐ณ Building a Blog Platform with Docker #5: Add a Dockerfile + Deploy to Clouderized
Quick one-liner: Write a Dockerfile, build an image, and deploy to Clouderized with one git push so your Flask blog goes live with automatic HTTPS and zero server babysitting.
๐ง Before We Start: URL Control for Blogger Migration
Before touching Docker, there's one fix from Episode 4 worth making now.
Right now our platform builds URLs from the filename: hey-markdown.md becomes /2026/04/hey-markdown.html. That works for new posts, but not for migrated Blogger URLs:
| Blogger URL | Filename-based URL |
|---|---|
/2026/03/docker-rootless-on-ubuntu-2026-guide.html |
/2026/03/docker-rootless-ubuntu.html |
We add canonical_url to frontmatter and use it for two things:
- The actual URL this platform serves the post at โ so migrated posts keep their Blogger paths
-
<link rel="canonical">โ tells Google this page is the same content as the old Blogger URL
Update content/posts/hey-markdown.md:
---
title: "Hey Markdown"
date: 2026-04-25
description: "The first post written in Markdown โ no more writing HTML by hand."
tags: [meta, blog]
canonical_url: "https://blog.dtio.app/2026/04/old-markdown.html"
---
The mismatch is intentional: filename and served path can differ.
Add a URL Helper to app.py
Open app.py. Add this import at the top:
from urllib.parse import urlparse
Then add a get_post_path() function below parse_post():
def get_post_path(meta):
"""Determine the URL path for a post from its canonical_url."""
if meta.get('canonical_url'):
return urlparse(meta['canonical_url']).path
# Fallback: auto-generate from date + slug
slug = meta.get('slug', 'unknown')
date = meta.get('date', '')
if hasattr(date, 'year'):
return f'/{date.year}/{date.month:02d}/{slug}.html'
return f'/{slug}.html'
This helper extracts the URL path from canonical_url, with a date+slug fallback.
Update get_all_posts()
In the existing get_all_posts() function, add the path field:
def get_all_posts():
posts = []
posts_dir = 'content/posts'
for filename in os.listdir(posts_dir):
if not filename.endswith('.md'):
continue
filepath = os.path.join(posts_dir, filename)
post = parse_post(filepath)
post['slug'] = filename[:-3]
post['path'] = get_post_path(post)
posts.append(post)
posts.sort(key=lambda p: p.get('date', ''), reverse=True)
return posts
Each post now has a path field โ the exact URL it should be served at.
Update the Route
Find the existing route in app.py:
@app.route('/<int:year>/<int:month>/<slug>')
def post(year, month, slug):
filepath = f'content/posts/{slug}.md'
if not os.path.exists(filepath):
abort(404)
post = parse_post(filepath)
return render_template('post.html', post=post)
Replace it with this dynamic lookup:
@app.route('/<int:year>/<int:month>/<path:slug>.html')
def post(year, month, slug):
target_path = f'/{year}/{month:02d}/{slug}.html'
all_posts = get_all_posts()
matching_post = next((p for p in all_posts if p['path'] == target_path), None)
if not matching_post:
abort(404)
post_data = parse_post(f'content/posts/{matching_post["slug"]}.md')
post_data['path'] = matching_post['path']
return render_template('post.html', post=post_data)
This route matches .html URLs, finds the matching post['path'], and loads the correct markdown file.
Update the Homepage Links
In templates/index.html, replace the manually constructed URL with the post's path:
<a href="{{ post.path }}">{{ post.title }}</a>
And for the "Read more" link:
<a href="{{ post.path }}" class="text-teal-400 text-sm hover:text-teal-300 font-medium transition-colors duration-200">
Read more โ
</a>
Add the Canonical Link Tag
In templates/post.html, add the canonical link tag inside <head>, after the meta description:
<meta name="description" content="{{ post.description }}">
{% if post.canonical_url %}
<link rel="canonical" href="{{ post.canonical_url }}">
{% endif %}
The {% if %} guard keeps the tag optional for posts without canonical_url.
Updated Frontmatter Schema
This is now the full frontmatter schema going forward:
| Field | Used for |
|---|---|
title |
<title> tag, <h1> on post page, post list |
date |
URL generation, sorting, display |
description |
<meta name="description">, excerpt on homepage |
tags |
Tag pills on post page, eventually tag pages in Ep12 |
canonical_url |
Homepage links + <link rel="canonical"> for Blogger migration (optional) |
When migrating posts, set canonical_url to match the original Blogger URL.
โ Step 1: Verify Everything Works Locally
Before touching Docker, make sure the platform still runs correctly after the URL changes.
Activate your venv and run the app:
$ source venv/bin/activate
$ python app.py
Visit http://localhost:8000 and check:
- Homepage โ posts listed, links point to the correct paths
-
Post page โ renders correctly,
<link rel="canonical">is present in the HTML source (view source and search forcanonical) - Tag pills โ display correctly on post pages
Once everything works locally, we're ready to containerise it.
๐ฆ Step 2: Add a .dockerignore
Before writing the Dockerfile, tell Docker what to leave out of the image. Create .dockerignore in the project root:
venv/
__pycache__/
*.pyc
.git/
.gitignore
This keeps the image lean and avoids shipping local environment files.
๐ Step 3: Write the Dockerfile
Create Dockerfile in the project root:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "app.py"]
Why this order matters:
- copy
requirements.txtfirst so dependency install can be cached - copy app code after that so normal code edits rebuild quickly
๐จ Step 4: Build the Image
Make sure you're in the project root (where the Dockerfile is):
$ docker build -t tioblog .
When it finishes, verify the image exists:
$ docker images tioblog
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
tioblog:latest 05dbc52b9852 231MB 55.6MB
๐ Step 5: Run It
$ docker run -p 8000:8000 tioblog
Visit http://localhost:8000. The blog loads โ same as before, but now running inside a container.
๐ Step 6: Add .gitignore
Before pushing to Clouderized, make sure you're not committing things that don't belong in the repo. Create .gitignore in the project root:
venv/
__pycache__/
*.pyc
.env
This keeps your virtual environment, cache files, and any local config out of version control.
๐ Step 7: Deploy to Clouderized
Clouderized is the deployment target in this series because it is optimized for simple container app shipping: push to Git, auto-build, auto-deploy, HTTPS on by default.
For this blog, deployment is:
$ git init
$ git add .
$ git commit -m "Initial commit for tioblog"
$ git branch -M main
$ git remote add origin https://git.clouderized.com/davidtio/tioblog.git
$ git push -u origin main
https://git.clouderized.com/davidtio/tioblog.git uses davidtio as the Clouderized username and tioblog as the project name.
For your app, use:
https://git.clouderized.com/<username>/<project>.git
After a minute or two, your blog is live at:
https://davidtio-tioblog.clouderized.com
Same Dockerfile, now running publicly with HTTPS.
Add Your Custom Domain
Clouderized also supports custom domains. For this blog, production runs at blog.dtio.app.
- Go to
https://dash.clouderized.com - Open the
tioblogapp card - Click
Domains - Add your custom domain (for example
blog.dtio.app) - Copy the
cfargotunneltarget shown by Clouderized - Create a
CNAMErecord for your domain and point it to thecfargotunneltarget
After DNS propagates, your app is served on your own domain while Clouderized continues handling routing and HTTPS.
Why this matters for small creator platforms:
- you keep one deployment unit (your Docker image)
- you avoid hand-maintained reverse proxy and certificate setup
- you can ship content and app changes with the same Git workflow
๐งช Step 8: Verify It's Live
Open your browser and check the live URL:
https://davidtio-tioblog.clouderized.com
Confirm the homepage loads, posts are listed, and a .html post URL works:
https://davidtio-tioblog.clouderized.com/2026/04/hey-markdown.html
This matches Blogger-style URLs for smoother migration.
Also verify HTTPS is active and the cert is valid. On Clouderized this is automatic, so you can focus on content and product work instead of infra chores.
โ What You've Built
tiohub-blog/
โโโ Dockerfile โ new
โโโ .dockerignore โ new
โโโ app.py (get_post_path, post['path'] in get_all_posts, route adds post['path'])
โโโ requirements.txt
โโโ static/
โ โโโ js/
โ โโโ tailwind.config.js
โ โโโ code-blocks.js
โโโ templates/
โ โโโ index.html (links use {{ post.path }} instead of manual URLs)
โ โโโ post.html (<link rel="canonical"> added)
โโโ content/
โโโ posts/
โโโ hey-markdown.md (canonical_url added to frontmatter)
โโโ second-post.md
What changed:
-
canonical_urlandpost.pathnow drive stable post URLs - dynamic route resolves URL path to the right markdown file
- homepage links use
{{ post.path }} - optional canonical tag added to
post.html -
Dockerfile,.dockerignore, and.gitignoreadded - app runs in container with
docker build+docker run - deployment pushed to Clouderized for public HTTPS hosting
- custom domain can be mapped in
Domainswith DNS pointed to the providedcfargotunneltarget - deployment flow is Git-native, which keeps publish operations repeatable for future series posts
๐ Coming Up
One problem: every time you edit a post, you have to rebuild the image to see the change. That defeats the purpose of a content-driven blog.
Next episode: bind mounts. We'll mount the content/ folder from your machine directly into the container โ edit a post, refresh the page, see the change instantly. No rebuild needed.
Found this helpful? Share it with your network or drop a comment below.
SEO Metadata
- Title: Building a Blog Platform with Docker #5: Add a Dockerfile + Deploy to Clouderized
- Meta Description: Write a Dockerfile for your Flask blog, build the image, and deploy it to clouderized.com โ push to git.clouderized.com/davidtio/tioblog, auto-build, auto-route, and auto-HTTPS at davidtio-tioblog.clouderized.com.

Top comments (0)