DEV Community

David Tio
David Tio

Posted on • Originally published at blog.dtio.app

Building a Blog Platform with Docker #5: Add a Dockerfile + Deploy to Clouderized

๐Ÿณ 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:

  1. The actual URL this platform serves the post at โ€” so migrated posts keep their Blogger paths
  2. <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"
---
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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 %}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 for canonical)
  • 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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

Why this order matters:

  • copy requirements.txt first 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 .
Enter fullscreen mode Exit fullscreen mode

When it finishes, verify the image exists:

$ docker images tioblog
IMAGE            ID             DISK USAGE   CONTENT SIZE   EXTRA
tioblog:latest   05dbc52b9852        231MB         55.6MB
Enter fullscreen mode Exit fullscreen mode

๐Ÿš€ Step 5: Run It

$ docker run -p 8000:8000 tioblog
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

After a minute or two, your blog is live at:

https://davidtio-tioblog.clouderized.com
Enter fullscreen mode Exit fullscreen mode

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.

  1. Go to https://dash.clouderized.com
  2. Open the tioblog app card
  3. Click Domains
  4. Add your custom domain (for example blog.dtio.app)
  5. Copy the cfargotunnel target shown by Clouderized
  6. Create a CNAME record for your domain and point it to the cfargotunnel target

Clouderized Domains view for tioblog custom domain setup

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
Enter fullscreen mode Exit fullscreen mode

Confirm the homepage loads, posts are listed, and a .html post URL works:

https://davidtio-tioblog.clouderized.com/2026/04/hey-markdown.html
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

What changed:

  • canonical_url and post.path now 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 .gitignore added
  • app runs in container with docker build + docker run
  • deployment pushed to Clouderized for public HTTPS hosting
  • custom domain can be mapped in Domains with DNS pointed to the provided cfargotunnel target
  • 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)