How to Customize Your Own Wagtail Blog on GCP β€” Following Your 1st Setup

A practical guide to customizing a Wagtail CMS blog: separating content by audience, sourcing and resizing NASA space images, and overriding the default theme styles.

April 11, 2026  Β·  7 min read
Part 2: Customization Guide

How to Customize Your Own Wagtail Blog on GCP — Following Your 1st Setup

A practical guide to customizing a Wagtail CMS blog: separating content by audience, sourcing and resizing NASA space images, and overriding the default theme styles.

In Part 1, we covered the full technical setup of a Wagtail blog on GCP. But a blog that looks exactly like the default template isn't very memorable. This guide walks through the customization decisions I made for isupernova.io — and why I made them.


Why Customize at All?

The default Clean Blog theme from Start Bootstrap is clean and functional, but it has some limitations out of the box. The post preview titles are too large, there's no visual hierarchy between different content categories, and every page looks identical. More importantly, my blog serves two very different audiences:

  • /tousa/ — Korean-speaking readers looking for realistic US life guidance
  • /tech/ — Global English-speaking readers interested in AI and tech

Mixing these two audiences on one page with no separation would dilute both. The customization work was about creating clear, intentional paths for each reader.


Step 1: Separating Content by Audience

Architecture

The first major change was creating separate URL paths for each content track. In Wagtail, this means adding a SectionPage model as an intermediate layer between the homepage and individual blog posts.

Why separate paths?

Korean readers coming to /tousa/ should only see Korean content. Global readers sharing /tech/ links on LinkedIn should land on a page that feels English-first and professional. Without path separation, both audiences land on the same mixed homepage, which serves neither well.

Adding SectionPage to models.py

class SectionPage(Page):
    """Intermediate section page — e.g. /tousa/, /tech/"""
    subpage_types = ['home.BlogPage']

    class Meta:
        verbose_name = "Section Page"


class HomePage(Page):
    subpage_types = ['home.BlogPage', 'home.SectionPage']

    def get_context(self, request):
        context = super().get_context(request)
        # Use descendant_of to include posts in child sections
        context['blogpages'] = BlogPage.objects.descendant_of(self).live().order_by('-date')
        return context

πŸ’‘ Note the change from child_of(self) to descendant_of(self) on the homepage. Without this, posts moved into subsections disappear from the main listing.

After adding the model, run migrations and create the section pages in Wagtail Admin:

python manage.py makemigrations
python manage.py migrate

Then in Admin → Pages → Add child page → Section Page, create /tousa/ and /tech/, and use the Move function to relocate existing posts into the appropriate section.


Step 2: Choosing Space Images for Headers

Design

I'm personally fascinated by astronomy, so using NASA space imagery for the blog headers was a natural fit. Beyond personal taste, space imagery works well for a tech blog — it conveys scale, ambition, and curiosity.

Where to find NASA images

NASA provides free, high-resolution images for non-commercial use at:

  • images.nasa.gov — full image library
  • apod.nasa.gov — Astronomy Picture of the Day archive
  • hubblesite.org — Hubble Space Telescope gallery

The images chosen for each section were intentional:

Page Image Why
Blog posts Centaurus A galaxy (cena.jpg) Dramatic, visually striking, works on dark overlays
/tousa/ section North America from orbit Directly represents the destination for Korean readers
/tech/ section NASA Artemis + Moon Represents exploration and cutting-edge technology

Resizing images for web use

Large NASA images can be several megabytes. Before uploading, resize them to web-appropriate dimensions. I asked Claude to handle the resizing directly in the browser — no external software needed:

python3 << 'EOF'
from PIL import Image

images = {
    'cena.jpg': (1920, 600),
    'tousa-bg.jpg': (1920, 600),
    'tech-bg.jpg': (1920, 600),
}

for filename, (target_w, target_h) in images.items():
    path = f'mysite/static/assets/img/{filename}'
    img = Image.open(path)
    scale = target_w / img.width
    new_h = int(img.height * scale)
    img = img.resize((target_w, new_h), Image.LANCZOS)
    if new_h > target_h:
        top = (new_h - target_h) // 2
        img = img.crop((0, top, target_w, top + target_h))
    img.save(path, 'JPEG', quality=85)
    print(f'{filename}: {img.size}')
EOF

Uploading to GCP Cloud Shell

Once resized, uploading to Cloud Shell is straightforward — simply drag and drop the image files from your local file explorer directly into the Cloud Shell terminal window. No additional tools needed. The files land in your home directory:

cp ~/cena.jpg ~/mysite/static/assets/img/cena.jpg
cp ~/tousa-bg.jpg ~/mysite/static/assets/img/tousa-bg.jpg
cp ~/tech-bg.jpg ~/mysite/static/assets/img/tech-bg.jpg

Step 3: Adding Header Images to Templates

Templates

blog_page.html — Centaurus A header

<header class="masthead" style="background-image: url('{% static 'assets/img/cena.jpg' %}');
  background-size: cover; background-position: center; padding: 100px 0;">
  <div class="container text-center text-white">
    <h1 style="font-weight: 800; text-shadow: 2px 2px 4px rgba(0,0,0,0.7);">
      {{ page.title }}
    </h1>
    <p>{{ page.intro }}</p>
    <div>{{ page.date|date:"F j, Y" }} · {% widthratio words 200 1 %} min read</div>
  </div>
</header>

section_page.html — Different image per section

Since both /tousa/ and /tech/ use the same SectionPage model, use the page slug to conditionally load the right background image:

{% if page.slug == 'tousa' %}
<header class="masthead" style="background-image: url('{% static 'assets/img/tousa-bg.jpg' %}')">
{% else %}
<header class="masthead" style="background-image: url('{% static 'assets/img/tech-bg.jpg' %}')">
{% endif %}

Step 4: Post Preview Style Overrides

CSS

The default Clean Blog theme defines .post-preview styles with large font sizes that feel heavy on a modern blog. Rather than modifying the theme file directly, append override rules to the end of styles.css:

cat >> ~/mysite/static/css/styles.css << 'EOF'

/* Custom overrides */
.post-preview > a > .post-title {
  font-size: 1.2rem !important;
  margin-top: 1rem !important;
  font-weight: 700 !important;
}
.post-preview > a > .post-subtitle {
  font-size: 0.95rem !important;
  font-weight: 400 !important;
  color: #555 !important;
}
.post-preview > .post-meta {
  font-size: 0.85rem !important;
  color: #999 !important;
}
@media (min-width: 992px) {
  .post-preview > a > .post-title {
    font-size: 1.3rem !important;
  }
}
EOF

Step 5: Setting Up GCS for Persistent Media Storage

Storage

Cloud Run containers are stateless — any files uploaded through the Wagtail admin (images, documents) are lost when the container restarts. To fix this permanently, connect Google Cloud Storage as the media backend.

1. Create a GCS bucket

gsutil mb -l us-central1 gs://your-bucket-name

2. Grant the service account access

gsutil iam ch serviceAccount:[PROJECT_NUMBER]-compute@developer.gserviceaccount.com:objectAdmin gs://your-bucket-name

# Allow public read access (required for serving media files)
gsutil iam ch allUsers:objectViewer gs://your-bucket-name

3. Install django-storages

pip install django-storages[google] --break-system-packages
echo "django-storages[google]" >> ~/requirements.txt

4. Update production.py

STORAGES = {
    "default": {
        "BACKEND": "storages.backends.gcloud.GoogleCloudStorage",
    },
    "staticfiles": {
        "BACKEND": "whitenoise.storage.CompressedStaticFilesStorage",
    },
}

GS_BUCKET_NAME = 'your-bucket-name'
GS_DEFAULT_ACL = None
GS_QUERYSTRING_AUTH = False  # Use public URLs, not signed URLs
MEDIA_URL = 'https://storage.googleapis.com/your-bucket-name/'

πŸ’‘ Key Point: Set GS_QUERYSTRING_AUTH = False. Without this, django-storages tries to generate signed URLs which requires a private key — not available on Cloud Run's default service account. This causes a 500 error on the images admin page.


Step 6: Rebuild and Deploy

Deployment

All template and static file changes require a Docker rebuild and Cloud Run redeployment. To avoid typing the full command every time, save it as a shell script:

# Create deploy script
cat > ~/deploy.sh << 'EOF'
#!/bin/bash
echo "πŸš€ Building and deploying..."

gcloud builds submit --tag us-central1-docker.pkg.dev/[PROJECT_ID]/[REPO]/[APP] && \
gcloud run deploy [APP] \
    --image us-central1-docker.pkg.dev/[PROJECT_ID]/[REPO]/[APP] \
    --platform managed \
    --region us-central1 \
    --allow-unauthenticated \
    --env-vars-file ~/env.yaml \
    --add-cloudsql-instances [PROJECT_ID]:[REGION]:[INSTANCE] \
    --port 8000

echo "βœ… Done!"
EOF

chmod +x ~/deploy.sh

# Run it
~/deploy.sh

πŸ’‘ Save env.yaml in your home directory (~/env.yaml) rather than /tmp/. Cloud Shell sessions reset periodically and wipe /tmp/, which will cause the deployment to fail if the file disappears.