As a lifelong gamer, I play across several devices — handheld, phone, desktop. RetroArch can sync saves between them, but it requires a WebDAV server.

That constraint gave me an excuse to build something I’d been wanting anyway: a single storage endpoint I fully control, backed by S3, managed declaratively with NixOS. Once the WebDAV server existed for game saves, expanding it to handle music production stems, database backups, and photos was just a matter of adding path prefixes.

This post walks through the whole setup. The entire server configuration is a single file.

WebDAV as a Storage Protocol Link to heading

WebDAV (Web Distributed Authoring and Versioning) is an extension of HTTP that adds file management operations — upload, download, list, delete, move. It’s been around since 1996 and enjoys broad client support:

  • Desktop file managers — KDE Dolphin, GNOME Files, macOS Finder, Windows Explorer all mount WebDAV shares natively
  • Mobile apps — FolderSync, Solid Explorer, and many others support WebDAV as a sync backend
  • Emulators — RetroArch has built-in WebDAV cloud sync for game saves
  • CLI tools — rclone, curl, cadaver all speak WebDAV
  • Backup utilities — Duplicati and others can target WebDAV endpoints

Because it’s just HTTP under the hood, it works through firewalls, proxies, and NATs without special configuration. Any client that can make HTTPS requests can talk to your storage.

So how do we expose S3 — which speaks its own API — as a WebDAV endpoint? That’s where rclone comes in.

Architecture Link to heading

The design is intentionally minimal. Four components, each with a single responsibility:

flowchart LR subgraph Clients A1[RetroArch] A2[rclone CLI] A3[Dolphin] A4[curl] A5[Diffuse] end subgraph EC2["EC2 (t4g.small, NixOS)"] B[Caddy :443\nTLS + Auth + CORS] C[rclone serve webdav\n:8080 localhost] end D[(S3 Bucket\nversioned)] Clients -- HTTPS --> B B -- reverse proxy --> C C -- S3 API\nIAM role --> D
  • S3 — Durable, versioned object storage. Cheap at rest, pay-per-request. Handles the “don’t lose my data” concern.
  • rclone — Runs rclone serve webdav on localhost, translating WebDAV operations into S3 API calls. It only listens on 127.0.0.1, so it doesn’t handle auth or TLS itself.
  • Caddy — Internet-facing reverse proxy. Handles automatic TLS (via Let’s Encrypt), HTTP basic auth, CORS headers for browser-based clients, and forwards authenticated requests to rclone.
  • EC2 — A t4g.small ARM instance running NixOS. Stateless — it’s just a proxy. You can terminate and recreate it without losing data.

The EC2 instance uses an IAM instance role to authenticate with S3, so there are no AWS credentials stored on the server. Caddy handles user-facing authentication with HTTP basic auth over TLS.

This separation means each layer can be reasoned about independently. S3 versioning protects against accidental deletion. Caddy protects against unauthorized access. NixOS makes the whole thing reproducible.

Infrastructure with CloudFormation Link to heading

The AWS resources are defined in a single CloudFormation template. This provisions everything: the S3 bucket, EC2 instance, IAM role, security group, Elastic IP, and CloudWatch alarms.

The template creates an S3 bucket with versioning enabled and per-prefix lifecycle rules — game saves retain 30 days of old versions, backups transition to Glacier Instant Retrieval after 30 days for cost savings. It also sets up the EC2 instance with an IAM role that grants just the necessary S3 permissions — GetObject, PutObject, DeleteObject, and ListBucket — without allowing deletion of object versions (so S3 versioning actually protects the data).

# template.yaml — S3 bucket (abbreviated, see repo for full template)
Resources:
  SavesBucket:
    Type: AWS::S3::Bucket
    DeletionPolicy: Retain
    UpdateReplacePolicy: Retain
    Properties:
      VersioningConfiguration:
        Status: Enabled
      # Per-prefix lifecycle rules — different retention for different data
      LifecycleConfiguration:
        Rules:
          - Id: retroarch-version-expiry
            Status: Enabled
            Prefix: retroarch/
            NoncurrentVersionExpiration:
              NoncurrentDays: 30
          - Id: media-version-expiry
            Status: Enabled
            Prefix: media/
            NoncurrentVersionExpiration:
              NoncurrentDays: 30
          # Backups — move to Glacier Instant Retrieval after 30 days,
          # keep 90 days of old versions
          - Id: backups-to-glacier-ir
            Status: Enabled
            Prefix: backups/
            Transitions:
              - StorageClass: GLACIER_IR
                TransitionInDays: 30
            NoncurrentVersionExpiration:
              NoncurrentDays: 90
          - Id: abort-incomplete-uploads
            Status: Enabled
            AbortIncompleteMultipartUpload:
              DaysAfterInitiation: 7
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256

A few design decisions worth noting:

  • DeletionPolicy: Retain on the bucket — deleting the CloudFormation stack won’t delete your data
  • No s3:DeleteObjectVersion in the IAM policy — even if someone gains access to the instance role, they can’t purge version history
  • Per-prefix lifecycle rules — game saves get 30-day version retention, backups transition to Glacier IR after 30 days (cheap at rest, still fast to retrieve)
  • S3 public access block + encryption — defense-in-depth even though the bucket policy already restricts access
  • CloudWatch auto-recovery alarm — automatically migrates the instance to healthy hardware on system failures
  • Optional email alerting — if you provide an AlertEmail parameter, SNS will notify you when the instance is down

Deploy it with:

aws cloudformation deploy \
  --template-file template.yaml \
  --stack-name cloud-storage \
  --parameter-overrides \
    KeyPairName=your-key \
    SshCidr=YOUR_IP/32 \
    AlertEmail=you@example.com \
    LatestAmiId=ami-XXXXXXXXXXXXXXXXX \
  --capabilities CAPABILITY_IAM

With the infrastructure running, the next step is configuring the OS. This is where NixOS shines.

The NixOS Configuration Link to heading

The entire server — services, firewall, hardening, fail2ban, auto-upgrades — is defined in a single configuration.nix file. One declarative file that produces the same system every time, without imperative setup scripts or multi-step SSH sessions. The full configuration is in the repo — below are the key pieces.

Here’s the rclone service definition. It runs rclone as a systemd service, serving the S3 bucket as WebDAV on localhost:

# rclone WebDAV service — translates WebDAV to S3 API calls
# Only listens on localhost; Caddy handles internet-facing traffic
systemd.services.rclone-webdav = {
  description = "rclone WebDAV server (S3 backend)";
  after = [ "network-online.target" ];
  wants = [ "network-online.target" ];
  wantedBy = [ "multi-user.target" ];

  serviceConfig = {
    Type = "simple";
    ExecStart = lib.concatStringsSep " " [
      "${pkgs.rclone}/bin/rclone serve webdav"
      # Use IAM instance role for auth (env_auth=true), no keys on disk
      ":s3,provider=AWS,region=${secrets.s3Region},env_auth=true:${secrets.s3Bucket}"
      "--addr 127.0.0.1:8080"
      "--vfs-cache-mode minimal"
      "--vfs-cache-max-age 1h"
      "--cache-dir /var/cache/rclone"
      "--server-read-timeout 5m"
      "--server-write-timeout 5m"
    ];
    Restart = "always";
    RestartSec = 5;

    # systemd hardening — minimal attack surface
    NoNewPrivileges = true;
    ProtectSystem = "strict";
    ProtectHome = true;
    PrivateTmp = true;
    CacheDirectory = "rclone";
    DynamicUser = true;
  };
};

The --server-read-timeout and --server-write-timeout flags prevent zombie connections from tying up resources. CacheDirectory = "rclone" tells systemd to manage /var/cache/rclone for the service, which pairs with ProtectSystem = "strict" — without it, the VFS cache would have nowhere to write.

And Caddy, which handles TLS termination, basic auth, and CORS. The routing is path-based — each user only has access to their designated prefix:

# Caddy — automatic TLS + path-based auth + CORS + reverse proxy
services.caddy = {
  enable = true;
  virtualHosts.${secrets.domain} = {
    extraConfig = ''
      # CORS preflight for Diffuse (browser-based music player at diffuse.sh)
      @cors_preflight method OPTIONS
      handle @cors_preflight {
          header Access-Control-Allow-Origin "https://diffuse.sh"
          header Access-Control-Allow-Methods "GET, HEAD, PROPFIND, OPTIONS"
          header Access-Control-Allow-Headers "Authorization, Content-Type, Depth, Range"
          header Access-Control-Allow-Credentials "true"
          header Access-Control-Max-Age "86400"
          respond 204
      }

      # Each path prefix gets its own auth scope.
      # Example: RetroArch user can only access /retroarch/*
      @retroarch_path path /retroarch/*
      handle @retroarch_path {
          basic_auth {
              ${secrets.retroarchUser} ${secrets.retroarchPasswordHash}
              ${secrets.adminUser} ${secrets.adminPasswordHash}
          }
          reverse_proxy localhost:8080
      }

      # Same pattern for /media/* (dj user) and a catch-all (admin only).
      # See full config in the repo.
    '';
  };
};

Caddy automatically obtains and renews TLS certificates from Let’s Encrypt, so you don’t need certbot cron jobs or manual renewal.

The CORS configuration is needed because Diffuse is a static web app that talks directly to your WebDAV server from the browser. Without these headers, the browser would block cross-origin requests. The preflight handler responds to OPTIONS without requiring authentication.

The configuration also includes fail2ban to protect against brute-force attacks and bot scanning. The interesting part is how the auth filter avoids false positives on WebDAV clients:

# fail2ban filter — only matches 401s where credentials WERE provided
# (Authorization header present) but were wrong. Does NOT match the initial
# 401 challenge that non-preemptive clients like Dolphin/KIO trigger.
environment.etc."fail2ban/filter.d/caddy-auth.local".text = ''
  [Definition]
  failregex = "remote_ip":"<HOST>".*"Authorization":\["REDACTED"\].*"status":401
  datepattern = "ts":{Epoch}
  ignoreregex =
'';

WebDAV clients that don’t use preemptive authentication send every request without credentials first, receive a 401 challenge, then retry with the Authorization header. By only matching 401 responses where an Authorization header was present, we exclusively catch actual failed login attempts. This lets us use a strict threshold (5 failed attempts in 5 minutes) without risking false positives on legitimate clients.

There’s also a caddy-botscan jail that catches 404 floods from vulnerability scanners probing for /wp-admin, /.env, and similar paths. Both jails use progressive banning with exponential backoff — repeat offenders accumulate longer bans across jails.

The secrets (domain, passwords, SSH keys) live in a separate secrets.nix that isn’t tracked in git. Password hashes are generated with Caddy’s built-in tool:

nix-shell -p caddy --run "caddy hash-password --plaintext 'YOUR_PASSWORD'"

A couple of operational tweaks worth mentioning. The NixOS AMI ships with a small EFI partition (~249MB), which fills up quickly as you accumulate system generations. Limiting boot entries prevents that:

# Prevent /boot from filling up — NixOS AMIs have a small EFI partition
boot.loader.systemd-boot.configurationLimit = 2;

# Garbage-collect old generations daily (keep only last 3 days)
nix.gc = {
  automatic = true;
  dates = "daily";
  options = "--delete-older-than 3d";
};

# Deduplicate identical files in the Nix store
nix.settings.auto-optimise-store = true;

Once both files are in place on the server, a single command brings everything up:

nixos-rebuild switch

From this point forward, the system auto-upgrades daily, garbage-collects old generations, and restarts crashed services automatically. What does day-to-day maintenance look like? Editing the config file and running nixos-rebuild switch again. That’s it.

Connecting Clients Link to heading

The storage is organized by path prefix. Each client targets its own directory:

Use case URL
Game saves https://storage.yourdomain.com/retroarch/
Music (Diffuse) https://storage.yourdomain.com/media/music/
Backups https://storage.yourdomain.com/backups/
Media https://storage.yourdomain.com/media/

RetroArch Link to heading

RetroArch has native cloud sync support. Point it at your WebDAV endpoint:

Setting Value
Cloud Sync ON
Backend WebDAV
URL https://storage.yourdomain.com/retroarch/
Username retroarch
Password (your password)

Game saves now sync across every device running RetroArch — your handheld, your phone, your desktop — without touching any third-party service.

Diffuse (music player) Link to heading

Diffuse is a web-based music player that connects directly to storage backends — including WebDAV. It runs entirely in the browser as a static app hosted at diffuse.sh, which is why we configured CORS headers in Caddy.

Upload your music library with rclone:

rclone copy ~/Music cloud:media/music/ --progress

Then add it as a source in Diffuse:

Field Value
Type WebDAV
URL https://storage.yourdomain.com/media/music/
Username dj
Password (your password)

Diffuse reads metadata from the files themselves, so organize your library however you like — by artist, album, genre, or flat. It plays whatever your browser supports (MP3, FLAC, OGG, WAV, AAC, OPUS).

rclone (CLI) Link to heading

For backups and bulk transfers, rclone itself can talk to your endpoint:

# Create a remote pointing to your WebDAV server
rclone config create cloud webdav \
  url=https://storage.yourdomain.com \
  user=admin pass=$(rclone obscure 'PASSWORD')

# Sync a directory
rclone copy ./photos cloud:media/photos/ --progress

# Push a database backup
rclone copyto ./db.sql.gz cloud:backups/db-2026-07-27.sql.gz

File managers Link to heading

KDE Dolphin, GNOME Files, and macOS Finder can mount WebDAV shares directly. In Dolphin, type webdavs://storage.yourdomain.com in the address bar and authenticate.

curl (one-liners) Link to heading

For scripting and cron jobs:

# Upload
curl -T backup.tar.gz -u admin:PASSWORD \
  "https://storage.yourdomain.com/backups/backup.tar.gz"

# Download
curl -u admin:PASSWORD -O \
  "https://storage.yourdomain.com/backups/backup.tar.gz"

Tradeoffs and Cost Link to heading

Let’s be honest about what this setup is and isn’t.

What you get:

  • Around $5/month with a Savings Plan (about $12/month on-demand for t4g.small EC2 + minimal S3 usage)
  • Full control over your data — no third party reads your files
  • Universal client support via WebDAV
  • Declarative, reproducible server config with NixOS rollback safety
  • S3 versioning as a safety net against accidental deletion

What you don’t get:

  • A polished web UI (if you want one, consider FileBrowser in front of the same stack)
  • Automatic file conflict resolution (WebDAV is last-write-wins)
  • Mobile push sync (you’ll need a sync app that polls)
  • Zero ops — the EC2 instance is still yours to manage, though NixOS auto-upgrades handle most of it

The EC2 instance is entirely stateless. If it dies, you deploy a new one, copy the same configuration.nix, run nixos-rebuild switch, and you’re back online. Your data never left S3.

For the full source code — CloudFormation template, NixOS configuration, and secrets template — check out the repository on GitHub.