In this post, we containerize this Hugo static site using Nginx and migrate our deployment pipeline from Google App Engine to Google Cloud Run.
Previously, this site was deployed on Google App Engine Standard using a custom Go server to handle static routing and custom 404 fallbacks. Today, we shifted to a modern containerized approach using Nginx (Unprivileged) on Google Cloud Run, which allows us to serve the site securely and efficiently without maintaining a custom backend runtime.
Here are the key configuration files used to achieve this setup.
1. The Multi-Stage Dockerfile
To keep the production container image as small and secure as possible, we use a multi-stage Docker build.
- Stage 1 (Builder) uses the official Hugo image to compile our static site.
- Stage 2 (Runner) uses a lightweight, unprivileged Nginx image to serve the compiled files.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| # Stage 1: Build the Hugo site
FROM ghcr.io/gohugoio/hugo:latest AS builder
USER root
WORKDIR /src
COPY . .
# Run hugo build
RUN hugo --minify --gc
# Stage 2: Serve the static site using Nginx Unprivileged
FROM nginxinc/nginx-unprivileged:alpine-slim
# Copy the built site from the builder stage
COPY --from=builder /src/public /usr/share/nginx/html
# Copy our custom Nginx template for environment variable substitution
COPY default.conf.template /etc/nginx/templates/default.conf.template
EXPOSE 8080
|
2. Nginx Template Configuration (default.conf.template)
Cloud Run dynamically binds your container to a port using the $PORT environment variable. Because Nginx configuration files do not support environment variables natively, we use Nginx’s built-in envsubst template support. Nginx will automatically substitute ${PORT} at container startup.
We also enable gzip compression and secure headers (like CSP and frame options):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
| server {
listen ${PORT};
server_name localhost;
# Gzip Compression
gzip on;
gzip_vary on;
gzip_min_length 10240;
gzip_proxied any;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/x-javascript application/xml image/svg+xml;
gzip_disable "MSIE [1-6]\.";
# Security Headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ =404;
}
# Custom 404 page
error_page 404 /404.html;
location = /404.html {
root /usr/share/nginx/html;
internal;
}
}
|
3. Local Testing (docker-compose.yml)
To test the container locally, we use a simple Compose file that builds the Dockerfile and forwards port 8080:
1
2
3
4
5
6
7
| services:
web:
build: .
ports:
- "8080:8080"
environment:
- PORT=8080
|
Run it locally with:
1
| docker compose up --build
|
4. Updating the CI/CD Pipeline (.github/workflows/google.yml)
We replaced our previous App Engine deploy job. In the new workflow, we no longer need to setup Hugo on the GitHub runner. Instead, we authenticate with GCP and use gcloud run deploy to upload the source code to Google Cloud Build, which compiles the image and deploys it to Cloud Run.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
| name: Build and Deploy to Cloud Run
on:
push:
branches: [ "main" ]
jobs:
deploy:
name: Build & Deploy
runs-on: ubuntu-latest
permissions:
contents: 'read'
id-token: 'write'
steps:
- name: Checkout
uses: actions/checkout@v3
with:
submodules: recursive
fetch-depth: 0
- id: 'auth'
name: 'Authenticate to Google Cloud'
uses: 'google-github-actions/auth@v1'
with:
workload_identity_provider: '{workload_identity_provider}'
service_account: '{service_account}'
project_id: '{project_id}'
- name: 'Set up Cloud SDK'
uses: 'google-github-actions/setup-gcloud@v1'
- name: 'Deploy to Cloud Run'
run: |
gcloud run deploy tysamples \
--source . \
--region us-central1 \
--allow-unauthenticated \
--project {project_id}
|