Moving My Website From Netlify to Zero Z3 Storage

May 26, 2026

Why Move Away From Netlify?

My website has been running on Netlify for a while. It works, it's free, and it auto-deploys on every git push. So why change anything?

Two reasons. First, a friend runs his own infrastructure including an S3-compatible storage service called Zero Z3 and offered me hosting. Second, I noticed my site was loading slowly — WebPageTest reported a First Contentful Paint and Largest Contentful Paint both at 3.2 seconds. That is bad. Moving hosting gave me a good excuse to fix both things at once.

Making Next.js Fully Static

My website is built with Next.js 14. By default, Next.js runs as a Node.js server, which needs an actual server process. For a personal portfolio that is just HTML, CSS, and images, that is overkill. Next.js has an output: 'export' option that compiles everything into a flat folder of static files — no Node.js required.

I created next.config.js at the project root:

const nextConfig = {
  output: 'export',
  trailingSlash: true,
}
module.exports = nextConfig

The trailingSlash: true is important for S3 static hosting. Without it, Next.js generates files like blog/my-post.html. S3 static hosting expects blog/my-post/index.html. With trailing slashes enabled, Next.js generates the correct directory structure and S3 can resolve URLs properly.

Running npm run build now produces an out/ folder with everything needed to serve the site — no server process needed at runtime.

One catch: my site had a dynamic Open Graph image route at /og that generated preview images per blog post by reading request.url. This is fundamentally incompatible with static export since there is no server to handle the request. I removed the route and replaced the dynamic OG image references with a static fallback image.

Performance Fixes

While I was at it, WebPageTest had flagged several issues worth fixing.

The Avatar Image

The biggest problem was my profile photo. It was a 2119×2119 pixel JPEG straight from a Sony camera, processed in Lightroom, weighing 984 KB. It was displayed on screen at 192×192 pixels (w-48 in Tailwind). I was sending 11× more pixels than needed.

I converted it to WebP at 384×384 pixels (2× for retina screens) using ImageMagick:

magick avatar.jpeg -resize 384x384 -strip -quality 82 avatar.webp

The -strip flag removes EXIF metadata including GPS coordinates, camera model, and Lightroom history. Result: 15 KB WebP, down from 984 KB. A 98.5% reduction.

I updated the page to use a <picture> element so modern browsers get WebP and old browsers fall back to a compressed JPEG:

<picture>
  <source srcSet="avatar.webp" type="image/webp" />
  <img src="avatar-optimized.jpeg" fetchPriority="high" ... />
</picture>

The fetchPriority="high" attribute tells the browser to prioritise downloading this image early, since it is the Largest Contentful Paint element.

Blog Post Images

The same problem existed for images inside blog posts. A few examples:

| File | Before | After | |---|---|---| | devops.jpg | 661 KB | 236 KB WebP | | aws_meme.png | 437 KB | 48 KB WebP | | firewall_setup.jpeg | 283 KB | 208 KB WebP |

All converted with the same magick command and the MDX source files updated to reference the .webp versions.

Removing Dead Packages

My layout.tsx was loading @vercel/analytics and @vercel/speed-insights on every page. These are Vercel-specific packages that send telemetry to Vercel's servers. Outside of Vercel they do nothing except add JavaScript weight to every page load. Removed both.

Email Obfuscation

My email address was previously set in a useEffect hook to prevent bots from scraping it. The problem: the email appeared as a plain string in the compiled JavaScript bundle anyway. Any scraper that fetches the JS file finds it immediately.

I replaced the plain string with a base64-encoded version that is decoded at runtime:

setMailtoHref(atob('bWFpbHRvOm1lQHBhdWxlbHNlci5jb20='))

The email is no longer a plain string anywhere in the HTML or JS bundle. Casual scrapers that don't bother decoding base64 will not find it.

GitHub Actions CI/CD

With Netlify, deployment was automatic through their GitHub integration. Replacing that with my own pipeline meant writing a GitHub Actions workflow.

The workflow runs on every push to main:

name: Deploy

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci
      - run: npm run build

      - name: Install rclone
        run: curl https://rclone.org/install.sh | sudo bash

      - name: Sync to S3
        env:
          RCLONE_CONFIG_Z3_TYPE: s3
          RCLONE_CONFIG_Z3_PROVIDER: Ceph
          RCLONE_CONFIG_Z3_ACCESS_KEY_ID: ${{ secrets.S3_ACCESS_KEY }}
          RCLONE_CONFIG_Z3_SECRET_ACCESS_KEY: ${{ secrets.S3_SECRET_KEY }}
          RCLONE_CONFIG_Z3_ENDPOINT: ${{ secrets.S3_ENDPOINT_URL }}
          S3_BUCKET: ${{ secrets.S3_BUCKET }}
        run: rclone sync out/ "z3:${S3_BUCKET}" -v

A few things worth noting:

rclone instead of the AWS CLI. Zero Z3 uses Ceph RADOS Gateway under the hood. The AWS CLI has a known incompatibility with certain Ceph endpoints that produces a cryptic argument of type 'NoneType' is not a container or iterable Python error. rclone handles Ceph-based S3 endpoints much more reliably.

Credentials via environment variables, not a config file. Passing secrets as --flag ${{ secrets.KEY }} directly in the shell script means the value is expanded by GitHub's template engine before the shell sees it. If the secret contains special characters, they can corrupt the signature. Using the env: block passes the values as real environment variables, which rclone reads directly. rclone supports configuring remotes entirely through environment variables using the RCLONE_CONFIG_{REMOTE}_{OPTION} pattern.

rclone sync with --delete. This mirrors the out/ folder exactly to the bucket. Files that no longer exist locally (e.g. old images) are deleted from the bucket automatically on the next deploy.

Zero Z3 Storage Setup

Zero Z3 is an S3-compatible object storage service. Setting it up for static website hosting involved:

  1. Creating a bucket
  2. Generating S3 access credentials (access key + secret key)
  3. Enabling static website hosting in the portal, pointing the index document to index.html and error document to 404.html
  4. Enabling public read access on the bucket
  5. Adding the domain paulelser.com as a custom domain alias — the portal provisions the TLS certificate automatically

DNS Configuration

With static website hosting enabled, Zero Z3 assigned the bucket a dedicated subdomain:

resume.fra.s3.zeroservices.eu

In Squarespace DNS settings I updated the CNAME record to point there instead of the old Netlify subdomain.

Result

The same WebPageTest run after the changes:

  • Largest Contentful Paint dropped from 3.2 seconds to well under a second
  • The LCP element (the avatar image) went from 984 KB to 15 KB
  • Total page weight dropped significantly from removed Vercel scripts and compressed images
  • Deployments still happen automatically on every git push, just going to a self-hosted bucket instead of Netlify

The migration also removed two external dependencies (Netlify, Vercel analytics) and replaced them with infrastructure I have more control over.