Most of our hosted sites are statically rendered, built with tools like Hugo, Zola, or Jekyll. In general, all those site renderers take simplified input (usually Markdown) and generate well-defined HTML as output. This leaves the question: how can I host such a site?
There are specialized providers offering hosting for static sites, but as you know, we took a different path when building our core infrastructure. For our cloud, the common denominator for deployment is a container. And with that comes the question: how can I go from a static site build to a container that hosts the website?
So let’s start from scratch by generating a very simple hello world page with Hugo.
hugo new site hello
cd hello
git init
git submodule add https://github.com/theNewDynamic/gohugo-theme-ananke.git themes/ananke
echo "theme = 'ananke'" >> hugo.toml
To check the build, we can start the server locally:
hugo serve
Then our very own “Hello World” page appears under http://localhost:1313
So far so good. Now let’s consider putting this into a container.
Technically, we can go two different routes here:
To make our example more reproducible and less reliant on local environments, we’re choosing option 1 for this scenario. This also makes CI/CD pipelines cleaner since the build environment is fully defined in the Dockerfile.
A container image always starts with a base image. For this, we’re using Alpine Linux since it’s small (around 5MB) and provides enough tooling for our project.
We’ll use a multi-stage build, a technique supported by modern container build tools that lets us use one image for building and another for running. This keeps our final image small by excluding build tools we don’t need at runtime.
Stage 1: Build
FROM alpine AS build
RUN apk add --no-cache hugo
WORKDIR /src
ADD . .
RUN hugo --minify
In this stage, we:
--minify flag to produce optimized outputThe built site ends up in /src/public/.
Stage 2: Runtime
FROM alpine AS runner
RUN apk add --no-cache lighttpd
COPY --from=build /src/public /var/www/localhost/htdocs
EXPOSE 80
CMD ["lighttpd", "-D", "-f", "/etc/lighttpd/lighttpd.conf"]
In this stage, we:
-D)Here’s the complete Dockerfile combining both stages:
# Stage 1: Build the static site
FROM alpine AS build
RUN apk add --no-cache hugo
WORKDIR /src
ADD . .
RUN hugo --minify
# Stage 2: Serve with lighttpd
FROM alpine AS runner
RUN apk add --no-cache lighttpd
COPY --from=build /src/public /var/www/localhost/htdocs
EXPOSE 80
CMD ["lighttpd", "-D", "-f", "/etc/lighttpd/lighttpd.conf"]
Save this as Dockerfile in your Hugo project root.
You can use any OCI-compatible container tool like Podman or Docker. The examples below use docker, but podman works as a drop-in replacement.
To build the image:
docker build -t my-website .
To run it locally:
docker run -p 8080:80 --rm my-website
Your site is now available at http://localhost:8080
The -p 8080:80 flag maps port 8080 on your machine to port 80 inside the container. The --rm flag automatically removes the container when it stops.
This setup has several advantages:
Small image size: The final image contains only Alpine (~5MB) + lighttpd (~1MB) + your HTML files. No Node.js, no Ruby, no build tools bloating your production image.
Reproducible builds: The exact same Hugo version runs in CI as locally, eliminating “works on my machine” issues.
Fast startup: lighttpd starts in milliseconds, making this perfect for scale-to-zero deployments on DTZ.
Security: The production container has minimal attack surface - just a static file server with no dynamic runtime.
If you are building your image on an Apple Silicon Mac (ARM64) or another non-standard architecture, remember that servers typically run on AMD64 (x86_64). To ensure your container runs correctly on DTZ (and most other cloud providers), you should explicitly specify the target platform during the build.
Change your build command to:
docker build --platform linux/amd64 -t my-website .
This tells Docker to cross-compile the image for standard Linux servers, ensuring compatibility regardless of the machine you build on.
Once your image is built, you can push it to a container registry and deploy it on DTZ. If you’re using our container registry:
# Tag for DTZ registry
docker tag my-website YOUR_CONTEXT_ID.cr.dtz.dev/my-website:latest
# Login and push
docker login YOUR_CONTEXT_ID.cr.dtz.dev -u apikey
docker push YOUR_CONTEXT_ID.cr.dtz.dev/my-website:latest
Then create a container service in the DTZ dashboard pointing to your image. The service will automatically handle TLS certificates, scaling, and routing.
For automated deployments on every commit, check out our GitHub Action for seamless deployments.
The same pattern works for other generators. Here are the key changes:
For Zola:
FROM alpine AS build
RUN apk add --no-cache zola
WORKDIR /src
ADD . .
RUN zola build
For Jekyll:
FROM ruby:alpine AS build
RUN apk add --no-cache build-base
RUN gem install bundler jekyll
WORKDIR /src
ADD . .
RUN bundle install
RUN bundle exec jekyll build
The runtime stage stays the same - just copy from /src/public (Zola) or /src/_site (Jekyll) to lighttpd’s document root.
Containerizing static sites is straightforward once you understand the pattern: build in one stage, serve from another. The result is a tiny, fast, secure container that’s perfect for modern cloud deployments.
This approach aligns well with our philosophy at DTZ - minimal resource usage, fast cold starts, and infrastructure that scales to zero when not in use. A static site in a ~10MB container that starts instantly is about as efficient as web hosting gets.