Host static web sites on HCloud blob storage

How to host a static website on Hetzner cloud blob storage with minimal resources.

This is a follow up on my original post Build & deploy a Hugo site with Gitea/Forgejo actions. Almost two years later, I wanted to write down some experiences I had so that I could remember why I am where I am now πŸ˜…

Recap

Back then I used Cloudflare pages to host my blog. There’s still nothing wrong with this approach! There are multiple reasons why I moved on from this approach:

  1. Cloudflare pages in its free version only supports one level of subdomains for HTTPS. For the blog that would still be fine but I have also some documentation domains that I prefer to host on <prooject>.docs.icb4dc0.de rather than on <project>-docs.icb4dc0.de. Yes, it’s almost the same, but my inner monk says no
  2. I already receive bills from Hetzner, Google Cloud Platform (GCP), and Azure. If possible, I’d like to consolidate as many services as I can with Hetzner Cloud
  3. With Trump going rogue in the US, relying on American cloud providers is currently not a popular thing in Europe

As you might or might not know, AWS S3 buckets can directly be used to host static web content. They even support configuring an index document so when you hit www.icb4dc0.de the S3 serice takes care that you actually receive the index.html (or whatever you see fit) instead of a HTTP 404. Unfortunately, Hetzner Cloud blob storage does not support this. The docs provide an example on how to put a Nginx reverse proxy in front of the bucket - which is basically what I am now doing - I just tweaked this scenario “a little bit”.

Previously, instead of a Nginx reverse proxy or a small Python script, I used a WASM function I hosted on my Kubernetes cluster that handled the following things:

  • based on the requested Host header, route the traffic to a different storage bucket
  • if no specific document was requested, add the fallback to /index.html (this is a simplified version of what I actually did, but it captures the main idea)

at some point the maintenance of Spin, spin-kube and all of it became so annoying that I decided to ditch it. Don’t get me wrong β€” I’m still a huge fan of WASM/WASI. I believe it, rather than AI, could be one of the next big things, once we stabilize the spec and bring some “boringness” into the game. But for the time being, I needed a boring replacement and Nginx is just that.

Nginx for the win

Right now, I have a very boring nginx configuratoin file that actually only handles the blog and not all the documentation pages as well. In my experience, most of the time, it’s better not being smart but predictable, so when you come back to the topic 6 months later, you don’t need a lot of time to figure out what the hell you were thinking back then πŸ˜… Maybe, I’ll create a small helm chart that allows me to host such things with a simple values.yaml but that’s for later.

Of course, the very first approach was to use a well-known Nginx base image1 to prove whether the concept works. A quick chat with Devstral 2 Small and my blog was up and running again, yay! πŸŽ‰

The following Nginx configuration does the trick:2

server {
    listen       8080;
    server_name  localhost;

    location = / {
        rewrite ^ /index.html break;
        proxy_pass https://<bucket-name>.fsn1.your-objectstorage.com;
        proxy_set_header Host <bucket-name>.fsn1.your-objectstorage.com;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    location / {
        # If the request doesn't have a file extension, treat it as a directory and append index.html
        # This handles both /post and /post/ transparently
        if ($request_uri !~ \.) {
            rewrite ^(.+?)/?$ $1/index.html break;
        }
        proxy_pass https://<bucket-name>.fsn1.your-objectstorage.com;
        proxy_set_header Host <bucket-name>.fsn1.your-objectstorage.com;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    error_page  404              /404.html;

    # redirect server error pages to the static page /50x.html
    #
    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        proxy_pass https://<bucket-name>.fsn1.your-objectstorage.com/50x.html;
        proxy_set_header Host <bucket-name>.fsn1.your-objectstorage.com;
    }
}

You might notice some weird regex hacks in there, that mostly to handle trailing / (both if present or not) and to return an error page.

Resource “overhead”

One thing that still bothered me, is the resource consumption of this approach. Before going to Cloudflare pages, I already hosted my blog with Nginx, but, back then, I just built static container images that included the static build of the blog. In consequence, I wasted a lot of storage because I not only stored my blog resources in my container registry but also every nginx version I used to serve it πŸ˜… For reference, the current nginxinc/nginx-unprivileged:alpine image is ~57MB. The build size of my blog is 1.5 MB, and I suspect that most of it consists of styles and fonts. Therefore, the ratio was a bit off.

Granted, this issue does not apply to the latest approach but every Nginx instance still consumes ~12MB RAM. Yeah, I hear you. In an era where even well-engineered Python, Node.js, or C# applications consume at least 100MB in idle, what’s the big deal about 12MB? You are not wrong, but when I host my blog and three or four documentations and - because we are “cloud native” and everything - I have two Nginx instances for every page, this already sums up to ~120MB just for the delivery of static resources. Of course, I could now optimise again by using only two instances and let them host all pages, but that would again increase the complexity to manage the deployments and everything, so I went down a different road.

In my previous job, I already looked into compiling Nginx from source. Originally, I did that to get rid of OpenSSL to avoid false-positives when scanning our frontend container images that would prevent us from deploying new releases whenever there was an OpenSSL incident, although we did not even use OpenSSL in the containers because SSL termination was of course done in the Ingress layer.

With this in mind, I replicated roughly what I did back then and built a stripped down custom Nginx image. The new image was only <1.8MB, that’s more like it! But, considering we’re in IT, things are never this ’easy’. Removing OpenSSL was a great idea, but, surprisingly, Nginx needs OpenSSL to connect to a HTTPS proxy (not mentioning, that I also stripped away the proxy module in the first iteration but 🀫).

Still, I wanted to make sure that security is as good as possible. I was already aware that there are alternatives to OpenSSL, so I did a little bit of research and quickly found out that it is possible to compile Nginx with BoringSSL:3

FROM debian:trixie AS builder

ARG NGINX_VERSION="1.29.4"
ARG BORINGSSL_VERSION="0.20251124.0"

ARG CFLAGS="-I/usr/src/boringssl/include -flto -fmerge-all-constants -fno-unwind-tables -fvisibility=hidden -fuse-linker-plugin -Wimplicit -Os -s -ffunction-sections -fdata-sections -fno-ident -fno-asynchronous-unwind-tables -static -Wno-cast-function-type -Wno-implicit-function-declaration"
ARG LDFLAGS="-L/usr/src/boringssl/build -lstdc++ -L/usr/src/boringssl/build/crypto -flto -fuse-linker-plugin -static -s -Wl,--gc-sections"

WORKDIR /src

RUN apt-get update && \
    apt-get install -y cmake ninja-build build-essential pkg-config git curl golang jq zlib1g-dev libpcre2-dev

# Install BoringSSL
RUN git clone https://boringssl.googlesource.com/boringssl /usr/src/boringssl \
    && cd /usr/src/boringssl && git checkout --force --quiet "${BORINGSSL_VERSION}" \
    && mkdir -p /usr/src/boringssl/build \
    && cmake -GNinja -B/usr/src/boringssl/build -S/usr/src/boringssl -DCMAKE_BUILD_TYPE=RelWithDebInfo \
    && ninja -C /usr/src/boringssl/build

RUN git clone --depth 1 --branch "release-${NGINX_VERSION}" https://github.com/nginx/nginx.git

WORKDIR /src/nginx

RUN ./auto/configure \
    --with-cc-opt="$CFLAGS" \
    --with-ld-opt="$LDFLAGS" \
    --prefix=/usr/local/nginx \
    --sbin-path=/usr/bin/nginx \
    --conf-path=/etc/nginx/nginx.conf \
    --error-log-path=stderr \
    --pid-path=/tmp/nginx.pid \
    --lock-path=/tmp/nginx.lock \
    --user=nobody \
    --group=nogroup \
    --with-pcre \
    --with-threads \
    --with-stream \
    --with-stream_ssl_module \
    --with-stream_ssl_preread_module \
    --with-file-aio \
    --with-http_v2_module \
    --with-http_v3_module \
    --with-http_ssl_module \
    --with-http_gunzip_module \
    --with-http_gzip_static_module \
    --without-http_ssi_module \
    --without-http_access_module \
    --without-http_auth_basic_module \
    --without-http_browser_module \
    --without-http_map_module \
    --without-http_mirror_module \
    --without-http_autoindex_module \
    --without-http_geo_module \
    --without-http_split_clients_module \
    --without-http_userid_module \
    --without-http_empty_gif_module \
    --without-http_referer_module \
    --without-http_fastcgi_module \
    --without-http_uwsgi_module \
    --without-http_scgi_module \
    --without-http_grpc_module \
    --without-http_memcached_module \
    --without-http_limit_conn_module \
    --without-http_limit_req_module \
    --without-http_upstream_hash_module \
    --without-http_upstream_ip_hash_module \
    --without-http_upstream_least_conn_module \
    --without-http_upstream_random_module \
    --without-http_upstream_keepalive_module \
    --without-http_upstream_zone_module

RUN make && \
    make install

FROM scratch

COPY config/group /etc/group
COPY config/passwd /etc/passwd
COPY --chmod=755 config/nginx /etc/nginx

COPY --from=builder /usr/bin/nginx /usr/bin/nginx

USER nobody

EXPOSE 8080
VOLUME [ "/tmp" ]

ENTRYPOINT [ "/usr/bin/nginx" ]
CMD [ "-g", "daemon off;" ]

As you can see, it is “relatively straight forward” πŸ˜‚ as far as compiling something implemented in C/C++ is ever “straight forward”. Interestingly, BoringSSL requires Go to be installed for some reason. It is also worth noting that Nginx has some documentation about how to compile Nginx with BoringSSL because that also allows to enable HTTP3 (although that does currently not work for me because Hetzner Cloud load balancers do not support it yet). The final image is now a little bit less than 4MB, which is still okay and the memory consumption is down to less than 3.6MB which is also great!

There are certainly still some things that I could improve, but, for the time being I am happy because I overengineered it a little bit but in a way that won’t hurt me in my daily life πŸ˜‚.


  1. unprivileged, because, safety first kids! ↩︎

  2. alternatively see also ↩︎

  3. alternatively see also ↩︎