featured image

Breaking Free from Cloud Oligopolies: Architecting a Zero-Trust Media and Document Vault with Immich, Nextcloud, and Tailscale

This tutorial provides a comprehensive, technical deep dive into building a secure, self-hosted media and document vault using Immich and Nextcloud, all while implementing Tailscale for zero-trust remote access. It covers the architectural decisions behind optimizing ZFS datasets for performance, configuring Docker containers for seamless integration, and establishing a secure networking layer that eliminates the need for public-facing ports.

Published

Tue Nov 11 2025

Technologies Used

Tailscale TrueNAS Scale Docker Immich Nextcloud ZFS
Advanced 43 minutes

Google Photos and Google Drive are convenient right up until you hit a storage limit, a price hike, or the moment you actually read the terms of service. A nine-year-old PC running TrueNAS Scale can handle everything they do — but running containers on ZFS without understanding how the file system allocates blocks will trash your hard drives faster than the workload itself.

This tutorial covers deploying Immich as a Google Photos replacement and Nextcloud as a Google Drive replacement, with Tailscale for zero-trust remote access. The storage configuration is where this gets non-trivial: I’ll explain why the default Immich dataset layout causes write amplification on mechanical drives and how to fix it.

What You Need to Understand First

  • How ZFS datasets work and how Docker volume mounts map to them
  • Docker Compose for declarative deployments (we’re using Dockge as a UI, but the YAML is identical)
  • How Tailscale or Cloudflare Tunnels route traffic without exposing raw ports to the internet

Environment:

  • TrueNAS Scale (Debian-based) or any Debian/Ubuntu server running Docker
  • ZFS pools configured — examples assume a pool named tank
  • Docker Engine 24.x+ and Docker Compose v2.x+

The Architecture: Storage Vaults and Stateless Workers

Before writing any YAML, it helps to think about what’s persistent versus what’s ephemeral.

graph TD
    subgraph "Zero-Trust Perimeter (Tailscale / Cloudflare)"
        Client[End User Device] -->|Encrypted Tunnel| Proxy[Reverse Proxy / Mesh IP]
    end

    subgraph "Docker Host (Ephemeral Logic)"
        Proxy -->|Port 2283| IS[Immich Server]
        Proxy -->|Port 8081| NC[Nextcloud Server]
        
        IS <--> ML[Immich Machine Learning]
        IS <--> Redis[(Redis Cache)]
        IS <--> PG[(PostgreSQL)]
    end

    subgraph "ZFS Pool: 'tank' (Persistent Storage Vaults)"
        IS -->|Volume Mount| UPL[Dataset: image/uploads]
        PG -->|Volume Mount| DB[Dataset: image/db]
        NC -->|Volume Mount| NCDATA[Dataset: nextcloud/data]
        NC -->|Volume Mount| NCCFG[Dataset: nextcloud/config]
    end

The ZFS datasets are the indestructible concrete vaults — persistent, snapshotted, backed up. The Docker containers are stateless workers that can crash, rebuild, or be replaced without touching the data. If a container needs a rebuild, pull the new image, point it at the same mount paths, and it comes back up with full state.

The Write Amplification Problem and How to Fix It

The default Immich installation documentation suggests up to seven separate ZFS datasets: library, uploads, thumbs, profile pictures, video transcodes, PostgreSQL data, and PostgreSQL backups. On paper this seems organized. In practice on mechanical drives it’s catastrophic.

ZFS is a copy-on-write file system. When you modify a file, ZFS writes new data to a new block, updates the pointer, and frees the old block. When Immich was configured with seven separate datasets, uploading a single photo triggered a cascade: the server wrote the raw photo to library, the ML container generated a thumbnail and wrote it to thumbs, and ZFS had to manage independent metadata, checksums, and block allocations for every micro-write across separate datasets on the spinning platters. The drive heads were jumping constantly.

The fix consolidates all media into two datasets:

/mnt/tank/configs/image/uploads   ← all media (raw uploads, thumbs, transcodes)
/mnt/tank/configs/image/db        ← PostgreSQL only
/mnt/tank/configs/nextcloud/config
/mnt/tank/configs/nextcloud/data

With all media in one dataset, ZFS can batch the asynchronous writes (raw photo + thumbnail + metadata) into a single Transaction Group and flush them in one contiguous sweep to the disk. Database writes go to their own dataset because ZFS can tune the block size (recordsize) differently for small, random ACID transactions versus large sequential writes.

Deploying the Immich Stack

services:
  immich-server:
    container_name: immich_server
    image: ghcr.io/immich-app/immich-server:release
    volumes:
      # All media in one consolidated dataset
      - /mnt/tank/configs/image/uploads:/usr/src/app/upload
      - /etc/localtime:/etc/localtime:ro
    env_file:
      - .env
    ports:
      - '2283:2283'
    depends_on:
      - redis
      - database
    restart: unless-stopped

  redis:
    container_name: immich_redis
    image: docker.io/redis:6.2-alpine@sha256:d6c2911ac51b289db208767581a5d154544f2b2ee2e14a7236ea90bbe1db09db
    restart: unless-stopped

  database:
    container_name: immich_postgres
    image: docker.io/tensorchord/pgvecto-rs:pg14-v0.2.0@sha256:90724186f0a451e5286046e848de2202cece253b8433d3ab2049d5bd32f91547
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_DB: ${DB_DATABASE_NAME}
    volumes:
      # Dedicated dataset for ACID-compliant database transactions
      - /mnt/tank/configs/image/db:/var/lib/postgresql/data
    restart: unless-stopped

The single /usr/src/app/upload mount handles everything Immich needs for media storage — raw uploads, transcoded videos, thumbnails, and profile pictures all go here. Separating these into individual datasets was the source of the write amplification.

Deploying Nextcloud

Nextcloud from linuxserver.io bundles most of its stack, but it needs the config and data layers separated:

  nextcloud:
    image: lscr.io/linuxserver/nextcloud:latest
    container_name: nextcloud
    environment:
      - PUID=1000
      - PGID=1000
      - TZ=America/New_York
    volumes:
      - /mnt/tank/configs/nextcloud/config:/config
      - /mnt/tank/configs/nextcloud/data:/data
    ports:
      - 8081:443
    restart: unless-stopped

Fixing the Trusted Domains Error

The first time you access Nextcloud through Tailscale or a Cloudflare Tunnel, you’ll hit “Access through untrusted domain.” This is intentional — Nextcloud rejects requests where the Host header doesn’t match an approved whitelist. Your reverse proxy is forwarding the request with a different host than Nextcloud expects.

Fix it by editing the config file in the persistent volume:

sudo su
cd /mnt/tank/configs/nextcloud/config/www/nextcloud/config/
nano config.php

Add your proxy domain to the trusted_domains array:

'trusted_domains' => 
array (
  0 => '10.99.0.191:8081',           // Local Docker bridge IP
  1 => 'nextcloud.serversatho.me',   // Cloudflare Tunnel / Tailscale domain
),

Save, refresh — the error disappears.

The PostgreSQL Permission Crash Loop

When you connect the image/db dataset to the Postgres container, you’ll hit catastrophic crash loops if you skip one step. TrueNAS defaults dataset ownership to root. The PostgreSQL container runs as UID 999 or 70 (depending on the image). It cannot initialize its write-ahead logs and dies instantly.

In TrueNAS, set the ACL on the image/db dataset to grant the Apps user (UID 999) full Read/Write/Execute permissions before the first container start. If you’re setting this up on plain Debian, chown -R 999:999 /mnt/tank/configs/image/db achieves the same thing.

Get this right before running docker compose up — the container will fail so fast that the error message scrolls off screen immediately, and it’s non-obvious why.

We respect your privacy.

← View All Tutorials

Related Projects

    Ask me anything!