Hosting a static site in a container

created: Wednesday, Dec 10, 2025

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.

Two Approaches to Containerization

Technically, we can go two different routes here:

  1. Put all the source into a container and run the build process inside
  2. Build locally and copy only the outputs into the container

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.

Building the Container Image

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:

The 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:

The Complete Dockerfile

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.

Building and Running Locally

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.

Why This Approach Works Well

This setup has several advantages:

  1. 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.

  2. Reproducible builds: The exact same Hugo version runs in CI as locally, eliminating “works on my machine” issues.

  3. Fast startup: lighttpd starts in milliseconds, making this perfect for scale-to-zero deployments on DTZ.

  4. Security: The production container has minimal attack surface - just a static file server with no dynamic runtime.

Architecture Considerations

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.

Deploying to DownToZero

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.

Adapting for Other Static Site Generators

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.

Wrapping Up

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.