Home / Self-Hosted Infrastructure and Security / Immich at Home: Secure Family Access Without Exposing the Server

Immich at Home: Secure Family Access Without Exposing the Server

Running Immich at home is always a tradeoff between security, convenience, and upload reliability. In practice, there are three common ways to expose it: direct access, VPN access, or Cloudflare in front of a reverse proxy. Each option changes both the attack surface and how well large uploads work.

Three access models

Direct access

The simplest setup is plain port forwarding. It works, but it also creates the biggest public attack surface. For a private photo archive, that is usually the riskiest option.

VPN access

A VPN such as WireGuard is the cleanest security model. The phone connects to the home network first and then reaches Immich like an internal service. That keeps the public attack surface small because the app is not exposed like a normal public website.

For a single user or a technical household, this is often the strongest option. The downside is usability. Every phone needs a VPN client, and it needs to be installed, configured, and active. For family members, that is often where the clean design starts to break.

Cloudflare in front

Cloudflare is the practical compromise. It gives the service a public entry point without exposing the Immich container directly to the internet. Immich’s own remote-access docs mention that a remote reverse proxy such as Cloudflare can improve security by hiding the server IP and making targeted attacks harder.

That makes Cloudflare attractive when the goal is simple family access without requiring a VPN on every device. The tradeoff is that Cloudflare becomes part of the request path, and that matters for large uploads.

Why Cloudflare was the right starting point

For a shared family photo service, convenience matters almost as much as security. A VPN-only setup is cleaner from a security perspective, but it is harder to roll out to non-technical users. Cloudflare gives a normal app-and-URL experience without publishing the Immich service directly.

So Cloudflare is a good first layer here. It is not more secure than a VPN-only design, but it is clearly better than direct exposure. And it solves the family access problem much better than WireGuard alone.

The catch: large uploads

The main downside shows up with large files. The Cloudflare path often becomes the limiting factor for uploads, and the commonly reported cap is around 100 MB on lower-tier proxied setups. That becomes visible very quickly with larger videos.

So the real issue is not that MP4 files are special. The issue is that the Cloudflare route is convenient and safer for public access, but it is not the best path for large uploads from trusted devices.

The practical answer

The useful design is a hybrid model. Keep Cloudflare as the public front door for normal and family-friendly access, but let trusted devices use a direct path to the home server through WireGuard and local DNS.

This keeps the convenience and reduced exposure of Cloudflare, while avoiding the upload bottleneck for your own phone or other trusted clients. The same Immich URL can still work everywhere. Only the network path changes.

How the setup works

The public path is layered so Immich is not exposed directly from Docker to the internet. Internet traffic goes to Cloudflare first, Cloudflare forwards through the named tunnel fyp, the tunnel sends requests to Nginx on localhost:8283, and Nginx proxies the main Immich hostname to the Immich container on 127.0.0.1:2283.

In parallel, there is also a direct local HTTPS path for trusted clients. On the home network, Nginx also listens on port 443 with TLS, so LAN devices can reach the same Immich service without using the Cloudflare route.

Sample traffic flow

Internet
  └─► Cloudflare
        └─► cloudflared tunnel "fyp"
              └─► localhost:8283 (nginx)
                    └─► http://127.0.0.1:2283 (Immich Docker)

Local network
  └─► nginx :443 SSL
        └─► http://127.0.0.1:2283 (Immich Docker)

Hostname layout

A clean version of this setup can use two hostnames:

  • photos.example-family.net for the full Immich app
  • share-photos.example-family.net for public share links only

That separation is useful because Nginx can apply different behavior by server_name even if Cloudflare sends both through the same local listener. The main app domain serves the full interface. The share domain can be restricted to share-related paths and return 403 for everything else.

Nginx design

The main Nginx block uses a dual-listen design. It listens on 127.0.0.1:8283 for cloudflared, and also on :443 ssl for direct local HTTPS access from LAN and VPN clients.

The proxy config needs the headers Immich expects: Host, X-Real-IP, X-Forwarded-Proto, and X-Forwarded-For. In a Cloudflare-based setup, real_ip_header CF-Connecting-IP is important so Nginx can recover the original client IP instead of only seeing the tunnel source. X-Forwarded-Proto https and X-Forwarded-Port 443 help Immich generate correct redirect URLs behind the proxy.

It also needs websocket support, large enough request-body limits, and timeouts that do not make the reverse proxy the first failure point during uploads.

Cloudflare tunnel role

The tunnel maps the public Immich hostnames to localhost:8283, and Nginx separates them by server_name. That lets one tunnel front multiple internal virtual hosts without exposing the application container directly.

That is the real value of the Cloudflare layer here. It gives an easy public entry point for family use and reduces direct exposure of the home server. But it is still not the ideal upload path for trusted clients and large files.

Local DNS and home access

To avoid sending trusted home traffic through Cloudflare, the home network uses a local DNS override for the main Immich hostname. FRITZ!Box supports handing out a local DNS server to LAN clients in the Home Network → Network → Network Settings → IPv4 area, so devices on Wi-Fi can resolve the Immich hostname to the local server IP instead of the public Cloudflare path.

That means the same URL works both publicly and privately. Outside the house it resolves through Cloudflare. Inside the house it resolves directly to the local Nginx endpoint and uses the local HTTPS path.

WireGuard as the trusted-device path

WireGuard extends the same idea to mobile devices. In this design, the phone connects home first, gets the private DNS behavior, and reaches the same Immich hostname through the direct path instead of through Cloudflare.

That is the clean workaround for large uploads. Family devices can keep using the easy public route. Trusted devices such as your own phone can use WireGuard and the local DNS route for direct, more reliable uploads.

Security assessment

This design is more secure than exposing Immich directly to the internet. Immich stays behind Docker and Nginx, and Cloudflare reduces direct exposure of the home IP.

At the same time, it is still not more secure than a VPN-only model. A VPN-only setup keeps the smallest public attack surface because the application itself is not exposed publicly at all.

That is why the hybrid approach works well in practice. Cloudflare is the family-friendly front door. WireGuard and local DNS provide the better path for trusted devices, especially when large uploads matter.

After all the Theorie – here is the detailed setup

Step 1 — Docker / Immich

Create /home/<user>/immich-app/.env:

UPLOAD_LOCATION=/data/photo_archive/immich_upload
DB_DATA_LOCATION=/home/<user>/immich-app/postgres

IMMICH_VERSION=release

DB_PASSWORD=<strong-random-password>
DB_USERNAME=postgres
DB_DATABASE_NAME=immich

Create docker-compose.yml:

name: immich

services:
  immich-server:
    container_name: immich_server
    image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
    volumes:
      - ${UPLOAD_LOCATION}:/usr/src/app/upload
      - /etc/localtime:/etc/localtime:ro
    env_file:
      - .env
    ports:
      - '2283:2283'
    depends_on:
      - redis
      - database
    restart: always

  immich-machine-learning:
    container_name: immich_machine_learning
    image: ghcr.io/immich-app/immich-machine-learning:${IMMICH_VERSION:-release}
    volumes:
      - model-cache:/cache
    env_file:
      - .env
    restart: always

  redis:
    container_name: immich_redis
    image: docker.io/redis:6.2-alpine
    restart: always

  database:
    container_name: immich_postgres
    image: ghcr.io/immich-app/postgres:14-vectorchord0.3.0-pgvectors0.2.0
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_USER: ${DB_USERNAME}
      POSTGRES_DB: ${DB_DATABASE_NAME}
      POSTGRES_INITDB_ARGS: '--data-checksums'
    volumes:
      - ${DB_DATA_LOCATION}:/var/lib/postgresql/data
    restart: always

volumes:
  model-cache:

Start Immich:

docker compose up -d

Immich is now reachable on the host at http://127.0.0.1:2283.


Step 2 — Cloudflare Setup

2a. Prerequisites

  • A domain managed in Cloudflare (nameservers pointing to Cloudflare)
  • Cloudflare Zero Trust enabled (free tier works)

2b. Create the tunnel in the Cloudflare dashboard

  1. Go to Cloudflare Dashboard → Zero Trust → Networks → Tunnels
  2. Click Create a tunnel → choose Cloudflared
  3. Name the tunnel (e.g. my-tunnel)
  4. Follow the on-screen install instructions — this provides the cloudflared package and generates the tunnel credentials file
  5. Add Public Hostnames:
Subdomain Domain Service
<main-subdomain> your-domain.org http://localhost:8283
<share-subdomain> your-domain.org http://localhost:8283

Cloudflare automatically creates the DNS CNAME records — no manual DNS entries needed.

2c. SSL/TLS setting

In the Cloudflare dashboard for your domain, go to SSL/TLS → Overview and set the encryption mode to Full (strict).

This means Cloudflare validates your server’s Let’s Encrypt certificate when connecting. It requires a valid cert on the server (set up in Step 3).

2d. Install cloudflared on the server

# Debian / Ubuntu
curl -L --output cloudflared.deb \
  https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb

2e. Tunnel config file

Create ~/.cloudflared/config.yml:

tunnel: <tunnel-name>
credentials-file: /home/<user>/.cloudflared/<tunnel-id>.json

ingress:
  - hostname: <main-subdomain>.your-domain.org
    service: http://localhost:8283
  - hostname: <share-subdomain>.your-domain.org
    service: http://localhost:8283
  - service: http_status:404   # catch-all: required, rejects unknown hostnames

no-autoupdate: true

Both hostnames point to nginx on port 8283. Nginx routes them to different behaviours by server_name.

2f. Run cloudflared as a systemd service

sudo cloudflared service install
sudo systemctl enable --now cloudflared

# Verify
sudo systemctl status cloudflared

Step 3 — TLS Certificate (Let’s Encrypt)

The certificate serves two purposes:

  • Enables HTTPS on port 443 for direct local network access
  • Satisfies Cloudflare’s Full (strict) validation when it connects to your server
sudo apt install certbot python3-certbot-nginx
sudo certbot certonly --nginx -d <main-subdomain>.your-domain.org

The --nginx flag lets certbot temporarily use nginx to complete the ACME challenge. The cert is stored under /etc/letsencrypt/live/<domain>/.

Auto-renewal

Certbot installs a systemd timer that renews automatically before expiry. After renewal, nginx must be reloaded to pick up the new cert:

# Create a deploy hook so nginx reloads after every renewal
echo "systemctl reload nginx" | sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh

# Verify the timer is active
sudo systemctl status certbot.timer

# Test renewal (dry run)
sudo certbot renew --dry-run

Step 4 — Nginx

Nginx handles two distinct server blocks for Immich. Add them to /etc/nginx/nginx.conf inside the http {} block.

map $http_upgrade $connection_upgrade { default upgrade; '' close; }

# --- Main Immich server ---
# Listens on 127.0.0.1:8283 (cloudflared) AND :443 (local network HTTPS)
server {
    listen 127.0.0.1:8283;
    listen 443 ssl http2;
    server_name <main-subdomain>.your-domain.org;

    ssl_certificate     /etc/letsencrypt/live/<main-subdomain>.your-domain.org/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/<main-subdomain>.your-domain.org/privkey.pem;

    client_max_body_size 50000M;
    client_body_timeout 600s;

    # Restore real visitor IP (cloudflared connects from localhost)
    real_ip_header CF-Connecting-IP;
    set_real_ip_from 127.0.0.1;

    # Required for OAuth to generate correct redirect URLs
    proxy_set_header Host              $host;
    proxy_set_header X-Forwarded-Host  $host;
    proxy_set_header X-Forwarded-Proto https;
    proxy_set_header X-Forwarded-Port  443;
    proxy_set_header X-Real-IP         $remote_addr;
    proxy_set_header X-Forwarded-For   $proxy_add_x_forwarded_for;

    # WebSocket support (required by Immich UI)
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection $connection_upgrade;
    proxy_request_buffering off;

    proxy_read_timeout 600s;
    proxy_send_timeout 600s;
    send_timeout       600s;

    location / {
        proxy_pass http://127.0.0.1:2283;
    }
}

# --- Share-only domain ---
# Serves Immich share links only; all other paths return 403
server {
    listen 8283;
    server_name <share-subdomain>.your-domain.org;

    set $backend "http://127.0.0.1:2283";

    proxy_set_header Host              $http_host;
    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;
    proxy_http_version 1.1;
    proxy_set_header Upgrade    $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_redirect off;
    proxy_read_timeout 600s;
    proxy_send_timeout 600s;
    send_timeout       600s;

    # Allow share paths and all static assets Immich needs to render them
    location ~* ^/(share|api|_app|assets/|static/|fonts/|favicon.*\.png|apple-icon-180\.png|favicon\.ico|.*\.js|.*\.css|.*\.woff2?|.*\.ttf) {
        proxy_pass $backend;
    }

    # Block everything else
    location / {
        return 403;
    }
}

Test and apply:

sudo nginx -t && sudo systemctl reload nginx

Key Design Notes

  • Port 8283 is the internal handoff between cloudflared and nginx — never exposed externally.
  • Port 2283 is Immich itself — only reachable from localhost via nginx.
  • real_ip_header CF-Connecting-IP is essential. Without it, Immich logs and any rate limiting see 127.0.0.1 for every request.
  • X-Forwarded-Proto: https and X-Forwarded-Port: 443 are required for Immich OAuth to build correct callback URLs.
  • The share-only domain allowlist must include /api and static file extensions — Immich loads assets from those paths even for share pages.
  • The map $http_upgrade $connection_upgrade block must be declared at the http {} level, not inside a server block.
  • The catch-all - service: http_status:404 at the end of the cloudflared ingress rules is required — cloudflared will refuse to start without it.