Self-Hosted Raspberry Pi Cloud: Nextcloud + Ente Photos Setup Guide

This guide builds a private Raspberry Pi cloud with encrypted storage, Nextcloud for files, Ente Photos for encrypted photo backup, Caddy HTTPS, Cloudflare DNS, No-IP DDNS, automated backups, and basic hardening.

Security note: this setup exposes web services to the internet. Use strong passwords, enable 2FA, keep off-device backups, and never expose database, MinIO, or app container ports directly.

Requirements

  • Raspberry Pi 4 or newer, preferably 8 GB RAM.
  • 64-bit Raspberry Pi OS Lite.
  • Two external HDDs or SSDs.
  • Reliable power supply and powered USB storage if needed.
  • Router access for DHCP reservation and port forwarding.
  • Domain name managed in Cloudflare.
  • No-IP hostname for dynamic DNS.
  • Resend account for SMTP email.
  • Computer with SSH access to the Pi.

What You Will Build

  • LUKS2 encryption on both drives.
  • One Btrfs storage pool at /srv/secure.
  • Nextcloud with PostgreSQL.
  • Ente Photos with Museum, PostgreSQL, and local MinIO-compatible storage.
  • Caddy reverse proxy with automatic HTTPS.
  • Cloudflare DNS-only CNAME records.
  • No-IP dynamic DNS client.
  • UFW firewall and SSH key-only login.
  • Nextcloud cron jobs.
  • Daily database/config backups.
  • Monthly Btrfs scrub.

Before You Start

Replace every value in angle brackets before running commands.

<PI_USER>                 Linux admin user on the Raspberry Pi
<PI_LAN_IP>               Static LAN IP, for example 192.168.1.50
<DOMAIN>                  Your domain, for example example.com
<DDNS_HOSTNAME>           Your No-IP hostname
<ADMIN_EMAIL>             Email used for HTTPS certificate notices
<NEXTCLOUD_ADMIN_USER>    Nextcloud admin username
<ENTE_OWNER_EMAIL>        First Ente owner email address
<DISK_A>                  First whole disk, for example /dev/sda
<DISK_B>                  Second whole disk, for example /dev/sdb

The final public URLs will be:

https://nextcloud.<DOMAIN>
https://photos.<DOMAIN>
https://ente-api.<DOMAIN>
https://albums.<DOMAIN>

In your router:

  • Reserve <PI_LAN_IP> for the Raspberry Pi.
  • Forward public TCP 80 to <PI_LAN_IP>:80.
  • Forward public TCP 443 to <PI_LAN_IP>:443.
  • Do not forward PostgreSQL, MinIO, Nextcloud 8080, Ente 8082, or web app ports.

This guide follows the current public docs for the Nextcloud Docker image, Ente self-hosting, and Caddy HTTPS.

1. Update Raspberry Pi OS

Run this on the Pi.

sudo apt update
sudo apt full-upgrade -y
sudo apt install -y \
  btrfs-progs ca-certificates cryptsetup curl git jq openssl smartmontools \
  unattended-upgrades ufw util-linux
sudo reboot

Reconnect after reboot.

ssh <PI_USER>@<PI_LAN_IP>

2. Set Up SSH Keys

Run this on your computer, not on the Pi.

ssh-keygen -t ed25519 -a 100
ssh-copy-id <PI_USER>@<PI_LAN_IP>
ssh <PI_USER>@<PI_LAN_IP>

Confirm SSH key login works before disabling password login later.

3. Save Your Main Settings

Run this on the Pi and replace the values first.

sudo install -d -m 700 /etc/private-cloud
sudo tee /etc/private-cloud/private-cloud.env >/dev/null <<'EOF'
PI_USER=<PI_USER>
PI_LAN_IP=<PI_LAN_IP>
DOMAIN=<DOMAIN>
DDNS_HOSTNAME=<DDNS_HOSTNAME>
ADMIN_EMAIL=<ADMIN_EMAIL>
NEXTCLOUD_ADMIN_USER=<NEXTCLOUD_ADMIN_USER>
ENTE_OWNER_EMAIL=<ENTE_OWNER_EMAIL>
DISK_A=<DISK_A>
DISK_B=<DISK_B>
EOF
sudo chmod 600 /etc/private-cloud/private-cloud.env

Load these values in every new shell.

set -a
. /etc/private-cloud/private-cloud.env
set +a

4. Install Docker

Install Docker from Docker's Debian repository.

sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/debian/gpg \
  -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc

. /etc/os-release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $VERSION_CODENAME stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list >/dev/null

sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo usermod -aG docker "$PI_USER"

Log out and back in, then verify Docker.

docker version
docker compose version

Users in the docker group effectively have root access. Only add trusted admins.

5. Prepare Encrypted Storage

This erases both selected disks. Check the disk names carefully.

lsblk -o NAME,PATH,SIZE,TYPE,FSTYPE,LABEL,MOUNTPOINTS,MODEL,SERIAL,TRAN

Unmount and wipe old filesystem signatures.

sudo umount "${DISK_A}"?* 2>/dev/null || true
sudo umount "${DISK_B}"?* 2>/dev/null || true
sudo wipefs --all "$DISK_A"
sudo wipefs --all "$DISK_B"

Create one encrypted LUKS2 container per disk.

sudo cryptsetup luksFormat --type luks2 "$DISK_A"
sudo cryptsetup open "$DISK_A" secure_a

sudo cryptsetup luksFormat --type luks2 "$DISK_B"
sudo cryptsetup open "$DISK_B" secure_b

Create a Btrfs pool. Data uses combined capacity; metadata is mirrored.

sudo mkfs.btrfs -f -d single -m raid1 -L secure_pool \
  /dev/mapper/secure_a /dev/mapper/secure_b

sudo mkdir -p /srv/secure
sudo mount -o noatime,compress=zstd:3 LABEL=secure_pool /srv/secure
sudo mkdir -p \
  /srv/secure/nextcloud/{html,db} \
  /srv/secure/ente \
  /srv/secure/backups/{db,config,logs}
sudo chmod 700 /srv/secure /srv/secure/backups

This pool is encrypted, but it is not a backup. If either drive fails, data loss is possible. Keep off-device backups.

Configure unlock and mount on boot.

DISK_A_UUID=$(sudo blkid -s UUID -o value "$DISK_A")
DISK_B_UUID=$(sudo blkid -s UUID -o value "$DISK_B")
POOL_UUID=$(sudo blkid -s UUID -o value /dev/mapper/secure_a)

printf 'secure_a UUID=%s none luks,nofail\n' "$DISK_A_UUID" | \
  sudo tee -a /etc/crypttab
printf 'secure_b UUID=%s none luks,nofail\n' "$DISK_B_UUID" | \
  sudo tee -a /etc/crypttab
printf 'UUID=%s /srv/secure btrfs defaults,noatime,compress=zstd:3,nofail 0 0\n' "$POOL_UUID" | \
  sudo tee -a /etc/fstab

sudo chmod 600 /etc/crypttab
findmnt /srv/secure
sudo btrfs filesystem usage /srv/secure

After each reboot, unlock both drives locally and verify the mount before starting containers.

findmnt /srv/secure

6. Deploy Nextcloud

Generate credentials and save them in a root-only environment file.

sudo install -d -m 700 /srv/secure/nextcloud
sudo tee /srv/secure/nextcloud/.env >/dev/null <<EOF
POSTGRES_DB=nextcloud
POSTGRES_USER=nextcloud
POSTGRES_PASSWORD=$(openssl rand -base64 32)
POSTGRES_HOST=db
NEXTCLOUD_ADMIN_USER=$NEXTCLOUD_ADMIN_USER
NEXTCLOUD_ADMIN_PASSWORD=$(openssl rand -base64 32)
NEXTCLOUD_TRUSTED_DOMAINS=nextcloud.$DOMAIN
OVERWRITECLIURL=https://nextcloud.$DOMAIN
OVERWRITEPROTOCOL=https
TRUSTED_PROXIES=127.0.0.1 172.16.0.0/12
APACHE_DISABLE_REWRITE_IP=1
EOF
sudo chmod 600 /srv/secure/nextcloud/.env

Create the Compose file.

sudo tee /srv/secure/nextcloud/compose.yaml >/dev/null <<'EOF'
services:
  db:
    image: postgres:18
    container_name: nextcloud-db
    restart: unless-stopped
    env_file: .env
    environment:
      PGDATA: /var/lib/postgresql/18/docker
    volumes:
      - ./db:/var/lib/postgresql

  app:
    image: nextcloud:apache
    container_name: nextcloud
    restart: unless-stopped
    depends_on:
      - db
    env_file: .env
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - ./html:/var/www/html
EOF

sudo chmod 600 /srv/secure/nextcloud/compose.yaml
cd /srv/secure/nextcloud
sudo docker compose up -d

Wait for the containers, then configure Nextcloud.

until sudo docker exec nextcloud-db pg_isready -U nextcloud -d nextcloud; do sleep 2; done

sudo docker exec -u www-data nextcloud php occ background:cron
sudo docker exec -u www-data nextcloud php occ config:app:set files default_quota \
  --value='200 GB'
sudo docker exec -u www-data nextcloud php occ user:setting \
  "$NEXTCLOUD_ADMIN_USER" files quota none

Enable audit logging and the end-to-end encryption app.

sudo docker exec -u www-data nextcloud php occ app:enable admin_audit
sudo docker exec -u www-data nextcloud php occ app:install end_to_end_encryption
sudo docker exec -u www-data nextcloud php occ app:enable end_to_end_encryption
sudo docker exec -u www-data nextcloud php occ app:disable encryption 2>/dev/null || true

Show the generated admin password when you are ready to log in.

sudo awk -F= '/^NEXTCLOUD_ADMIN_PASSWORD=/{print $2}' /srv/secure/nextcloud/.env

Create family users in the web admin page after HTTPS is working. Use Default quota, enable 2FA, and initialize E2EE from an official Nextcloud client.

7. Deploy Ente Photos

Create the Ente directory and credentials.

sudo install -d -m 700 /srv/secure/ente/{data,minio,postgres}
sudo tee /srv/secure/ente/.env >/dev/null <<EOF
PG_PASSWORD=$(openssl rand -base64 32)
MINIO_USER=minio-$(openssl rand -hex 8)
MINIO_PASSWORD=$(openssl rand -base64 32)
ENTE_KEY=$(openssl rand -base64 32)
ENTE_HASH=$(openssl rand -base64 64 | tr -d '\n')
ENTE_JWT=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '\n')
DOMAIN=$DOMAIN
EOF
sudo chmod 600 /srv/secure/ente/.env

Create the Compose file.

sudo tee /srv/secure/ente/compose.yaml >/dev/null <<'EOF'
services:
  museum:
    image: ghcr.io/ente/server:latest
    container_name: ente-museum
    restart: unless-stopped
    depends_on:
      postgres:
        condition: service_healthy
      minio:
        condition: service_started
    ports:
      - "127.0.0.1:8082:8080"
    volumes:
      - ./museum.yaml:/museum.yaml:ro
      - ./data:/data

  socat:
    image: alpine/socat:latest
    container_name: ente-socat
    restart: unless-stopped
    network_mode: service:museum
    depends_on:
      - museum
    command: "TCP-LISTEN:3200,fork,reuseaddr TCP:minio:3200"

  web:
    image: ghcr.io/ente/web:latest
    container_name: ente-web
    restart: unless-stopped
    env_file: .env
    environment:
      ENTE_API_ORIGIN: https://ente-api.${DOMAIN}
      ENTE_ALBUMS_ORIGIN: https://albums.${DOMAIN}
    ports:
      - "127.0.0.1:3000:3000"
      - "127.0.0.1:3002:3002"

  postgres:
    image: postgres:15
    container_name: ente-postgres
    restart: unless-stopped
    env_file: .env
    environment:
      POSTGRES_USER: pguser
      POSTGRES_PASSWORD: ${PG_PASSWORD}
      POSTGRES_DB: ente_db
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -q -d ente_db -U pguser"]
      interval: 10s
      timeout: 5s
      retries: 10
    volumes:
      - ./postgres:/var/lib/postgresql/data

  minio:
    image: minio/minio:latest
    container_name: ente-minio
    restart: unless-stopped
    env_file: .env
    command: server /data --address ":3200" --console-address ":3201"
    ports:
      - "127.0.0.1:3200:3200"
    volumes:
      - ./minio:/data
EOF

sudo chmod 600 /srv/secure/ente/compose.yaml

Create museum.yaml.

set -a
. /srv/secure/ente/.env
set +a

sudo tee /srv/secure/ente/museum.yaml >/dev/null <<EOF
db:
  host: postgres
  port: 5432
  name: ente_db
  user: pguser
  password: $PG_PASSWORD

s3:
  are_local_buckets: true
  use_path_style_urls: true
  b2-eu-cen:
    key: $MINIO_USER
    secret: $MINIO_PASSWORD
    endpoint: localhost:3200
    region: eu-central-2
    bucket: b2-eu-cen
  wasabi-eu-central-2-v3:
    key: $MINIO_USER
    secret: $MINIO_PASSWORD
    endpoint: localhost:3200
    region: eu-central-2
    bucket: wasabi-eu-central-2-v3
    compliance: false
  scw-eu-fr-v3:
    key: $MINIO_USER
    secret: $MINIO_PASSWORD
    endpoint: localhost:3200
    region: eu-central-2
    bucket: scw-eu-fr-v3

apps:
  public-albums: https://albums.$DOMAIN
  accounts: https://photos.$DOMAIN

key:
  encryption: $ENTE_KEY
  hash: $ENTE_HASH

jwt:
  secret: $ENTE_JWT
EOF

sudo chmod 600 /srv/secure/ente/museum.yaml

Start PostgreSQL and MinIO, create the required buckets, then start Ente.

cd /srv/secure/ente
sudo docker compose up -d postgres minio
sleep 20

sudo docker run --rm --network container:ente-minio \
  --entrypoint /bin/sh \
  --env-file /srv/secure/ente/.env \
  minio/mc -c '
    mc alias set local http://localhost:3200 "$MINIO_USER" "$MINIO_PASSWORD"
    mc mb --ignore-existing local/b2-eu-cen
    mc mb --ignore-existing local/wasabi-eu-central-2-v3
    mc mb --ignore-existing local/scw-eu-fr-v3
  '

sudo docker compose up -d
sudo docker compose ps
curl -fsS http://127.0.0.1:8082/ping

Create the first Ente account at https://photos.<DOMAIN> after Caddy is configured. Use <ENTE_OWNER_EMAIL> for the owner account and save the recovery key offline.

After the owner account exists, get its ID.

sudo docker exec ente-postgres psql -U pguser -d ente_db \
  -c 'SELECT user_id, email, creation_time FROM users ORDER BY creation_time;'

Add the owner ID to /srv/secure/ente/museum.yaml.

internal:
  admins:
    - <ENTE_OWNER_USER_ID>

Apply the change.

cd /srv/secure/ente
sudo docker compose up -d --force-recreate museum socat

Once all family accounts are registered and verified, disable new Ente registrations in /srv/secure/ente/museum.yaml.

Edit the file.

sudo nano /srv/secure/ente/museum.yaml

Add or update this block.

internal:
  admins:
    - <ENTE_OWNER_USER_ID>
  disable-registration: true

Recreate only the Ente API container and check its logs.

cd /srv/secure/ente
sudo docker compose up -d --force-recreate museum
sudo docker compose logs --tail=50 museum

Existing users can continue signing in and uploading, but new account creation will be rejected.

To temporarily reopen registration, change the same block.

internal:
  admins:
    - <ENTE_OWNER_USER_ID>
  disable-registration: false

Recreate museum again. Keep registration closed normally and open it only briefly while adding a known family member.

8. Set Ente Storage Quotas

Back up Ente's database before changing quotas.

STAMP=$(date -u +%Y%m%dT%H%M%SZ)
sudo docker exec ente-postgres pg_dump -U pguser -d ente_db | \
  gzip | sudo tee "/srv/secure/backups/db/ente-before-quota-${STAMP}.sql.gz" >/dev/null

Give the owner effectively unlimited storage. Ente represents this as 100 TB.

sudo docker exec ente-postgres psql -v ON_ERROR_STOP=1 -U pguser -d ente_db \
  -c "INSERT INTO storage_bonus (bonus_id,type,user_id,storage,valid_till)
      VALUES ('self-hosted-owner-unlimited','ADD_ON_SUPPORT',<ENTE_OWNER_USER_ID>,109951162777600,0)
      ON CONFLICT (bonus_id) DO UPDATE SET
      storage=EXCLUDED.storage,
      valid_till=EXCLUDED.valid_till,
      is_revoked=false,
      revoke_reason=NULL;"

Give each family member 200 GB total. Replace <USER_ID>.

sudo docker exec ente-postgres psql -v ON_ERROR_STOP=1 -U pguser -d ente_db \
  -c "INSERT INTO storage_bonus (bonus_id,type,user_id,storage,valid_till)
      VALUES ('family-default-<USER_ID>','ADD_ON_SUPPORT',<USER_ID>,204010946560,0)
      ON CONFLICT (bonus_id) DO UPDATE SET
      storage=EXCLUDED.storage,
      valid_till=EXCLUDED.valid_till,
      is_revoked=false,
      revoke_reason=NULL;"

For future accounts, install an hourly quota increase job.

sudo tee /etc/private-cloud/ente-family-quotas.sql >/dev/null <<'EOF'
INSERT INTO storage_bonus (bonus_id, type, user_id, storage, valid_till)
VALUES (
  'self-hosted-owner-unlimited',
  'ADD_ON_SUPPORT',
  <ENTE_OWNER_USER_ID>,
  109951162777600,
  0
)
ON CONFLICT (bonus_id) DO UPDATE SET
  storage = EXCLUDED.storage,
  valid_till = EXCLUDED.valid_till,
  is_revoked = false,
  revoke_reason = NULL;

INSERT INTO storage_bonus (bonus_id, type, user_id, storage, valid_till)
SELECT
  'family-default-' || s.user_id,
  'ADD_ON_SUPPORT',
  s.user_id,
  GREATEST(214748364800 - s.storage, 0),
  0
FROM subscriptions s
WHERE s.user_id <> <ENTE_OWNER_USER_ID>
ON CONFLICT (bonus_id) DO UPDATE SET
  storage = EXCLUDED.storage,
  valid_till = EXCLUDED.valid_till,
  is_revoked = false,
  revoke_reason = NULL;
EOF

sudo chmod 600 /etc/private-cloud/ente-family-quotas.sql

sudo tee /etc/cron.d/ente-family-quotas >/dev/null <<'EOF'
17 * * * * root /usr/bin/docker exec -i ente-postgres psql -v ON_ERROR_STOP=1 -U pguser -d ente_db < /etc/private-cloud/ente-family-quotas.sql >/dev/null 2>&1
EOF

sudo chmod 644 /etc/cron.d/ente-family-quotas
sudo docker exec -i ente-postgres psql -v ON_ERROR_STOP=1 -U pguser \
  -d ente_db < /etc/private-cloud/ente-family-quotas.sql

9. Configure No-IP Dynamic DNS

Create restricted No-IP credentials, then store them on the Pi.

sudo install -m 600 /dev/null /etc/private-cloud/noip-duc.env
sudo tee /etc/private-cloud/noip-duc.env >/dev/null <<'EOF'
NOIP_USERNAME=<NOIP_DDNS_KEY_USERNAME>
NOIP_PASSWORD=<NOIP_DDNS_KEY_PASSWORD>
NOIP_HOSTNAMES=all.ddnskey.com
EOF
sudo chmod 600 /etc/private-cloud/noip-duc.env

Run the No-IP client with a locked-down container profile.

sudo docker pull ghcr.io/noipcom/noip-duc:3.3.0
sudo docker run -d \
  --name noip-duc \
  --restart unless-stopped \
  --read-only \
  --cap-drop ALL \
  --security-opt no-new-privileges:true \
  --env-file /etc/private-cloud/noip-duc.env \
  ghcr.io/noipcom/noip-duc:3.3.0

sudo docker logs noip-duc

10. Configure Cloudflare DNS

Create a Cloudflare API token limited to DNS edit access for <DOMAIN>. Then run this.

read -rsp 'Cloudflare API token: ' CLOUDFLARE_API_TOKEN
echo
export CLOUDFLARE_API_TOKEN

ZONE_ID=$(curl -fsS \
  -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
  "https://api.cloudflare.com/client/v4/zones?name=$DOMAIN" | \
  jq -r '.result[0].id')

for NAME in nextcloud photos ente-api albums; do
  RECORD_ID=$(curl -fsS \
    -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
    "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records?type=CNAME&name=${NAME}.${DOMAIN}" | \
    jq -r '.result[0].id // empty')

  DATA=$(jq -n \
    --arg name "${NAME}.${DOMAIN}" \
    --arg content "$DDNS_HOSTNAME" \
    '{type:"CNAME", name:$name, content:$content, ttl:120, proxied:false}')

  if [ -n "$RECORD_ID" ]; then
    curl -fsS -X PUT \
      -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
      -H 'Content-Type: application/json' \
      --data "$DATA" \
      "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records/${RECORD_ID}" >/dev/null
  else
    curl -fsS -X POST \
      -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" \
      -H 'Content-Type: application/json' \
      --data "$DATA" \
      "https://api.cloudflare.com/client/v4/zones/${ZONE_ID}/dns_records" >/dev/null
  fi
done

unset CLOUDFLARE_API_TOKEN ZONE_ID RECORD_ID DATA

Cloudflare must show DNS only, not proxied. Caddy will handle HTTPS directly.

11. Configure Caddy HTTPS

Install Caddy.

sudo apt install -y caddy

Create the Caddyfile.

sudo tee /etc/caddy/Caddyfile >/dev/null <<EOF
{
    email $ADMIN_EMAIL
}

nextcloud.$DOMAIN {
    reverse_proxy 127.0.0.1:8080
    header Strict-Transport-Security "max-age=31536000; includeSubDomains"
}

photos.$DOMAIN {
    reverse_proxy 127.0.0.1:3000
    header Strict-Transport-Security "max-age=31536000; includeSubDomains"
}

ente-api.$DOMAIN {
    reverse_proxy 127.0.0.1:8082
    header Strict-Transport-Security "max-age=31536000; includeSubDomains"
}

albums.$DOMAIN {
    reverse_proxy 127.0.0.1:3002
    header Strict-Transport-Security "max-age=31536000; includeSubDomains"
}
EOF

sudo caddy fmt --overwrite /etc/caddy/Caddyfile
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl enable --now caddy
sudo systemctl reload caddy

Test from outside your home network.

curl -I "https://nextcloud.$DOMAIN"
curl -I "https://photos.$DOMAIN"
curl -I "https://ente-api.$DOMAIN/ping"
curl -I "https://albums.$DOMAIN"

12. Configure Email

In Resend, verify notify.<DOMAIN> as a sending domain. Add the DNS records Resend provides.

Add SMTP to Ente.

sudo tee -a /srv/secure/ente/museum.yaml >/dev/null <<EOF

smtp:
  host: smtp.resend.com
  port: 465
  username: resend
  password: <ENTE_RESEND_API_KEY>
  email: ente@notify.$DOMAIN
  sender-name: Ente
  encryption: tls
EOF

sudo chmod 600 /srv/secure/ente/museum.yaml
cd /srv/secure/ente
sudo docker compose restart museum

Configure Nextcloud SMTP without placing the API key in shell history.

read -rsp 'Nextcloud Resend API key: ' NEXTCLOUD_SMTP_PASSWORD
echo

sudo docker exec -u www-data nextcloud php occ config:system:set mail_smtpmode --value=smtp
sudo docker exec -u www-data nextcloud php occ config:system:set mail_smtphost --value=smtp.resend.com
sudo docker exec -u www-data nextcloud php occ config:system:set mail_smtpport --type=integer --value=587
sudo docker exec -u www-data nextcloud php occ config:system:set mail_smtpsecure --value=tls
sudo docker exec -u www-data nextcloud php occ config:system:set mail_smtpauth --type=boolean --value=true
sudo docker exec -u www-data nextcloud php occ config:system:set mail_smtpname --value=resend
sudo docker exec -u www-data nextcloud php occ config:system:set mail_smtppassword \
  --value="$NEXTCLOUD_SMTP_PASSWORD"
sudo docker exec -u www-data nextcloud php occ config:system:set mail_from_address --value=nextcloud
sudo docker exec -u www-data nextcloud php occ config:system:set mail_domain --value="notify.$DOMAIN"
unset NEXTCLOUD_SMTP_PASSWORD

Send test emails from both apps before inviting family members.

13. Enable Firewall and SSH Hardening

Allow only SSH, HTTP, and HTTPS.

sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
sudo ufw status verbose

Keep a second SSH session open, then disable password login.

sudo tee /etc/ssh/sshd_config.d/90-private-cloud-hardening.conf >/dev/null <<EOF
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
X11Forwarding no
MaxAuthTries 3
AllowUsers $PI_USER
EOF

sudo sshd -t
sudo systemctl reload ssh

Enable unattended security updates.

sudo tee /etc/apt/apt.conf.d/52private-cloud-unattended-upgrades >/dev/null <<'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::AutocleanInterval "7";
Unattended-Upgrade::Automatic-Reboot "false";
EOF

sudo systemctl enable --now unattended-upgrades.service

14. Schedule Nextcloud Cron

Nextcloud needs background jobs for cleanup, notifications, and file processing.

sudo tee /etc/systemd/system/nextcloud-cron.service >/dev/null <<'EOF'
[Unit]
Description=Run Nextcloud cron.php
Requires=docker.service
After=docker.service

[Service]
Type=oneshot
ExecStart=/usr/bin/docker exec -u www-data nextcloud php -f /var/www/html/cron.php
EOF

sudo tee /etc/systemd/system/nextcloud-cron.timer >/dev/null <<'EOF'
[Unit]
Description=Run Nextcloud cron every five minutes

[Timer]
OnBootSec=5min
OnUnitActiveSec=5min
Persistent=true

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now nextcloud-cron.timer

15. Configure Backups

These local backups help with bad upgrades and accidental changes. They do not replace off-device backups.

Install the backup script.

sudo tee /usr/local/sbin/private-cloud-backup >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

stamp=$(date -u +%Y%m%dT%H%M%SZ)
base=/srv/secure/backups
install -d -m 700 "$base/db" "$base/config" "$base/logs"
exec > >(tee -a "$base/logs/backup-$stamp.log") 2>&1

docker exec nextcloud-db pg_dumpall -U nextcloud | gzip -9 > \
  "$base/db/nextcloud-postgres-$stamp.sql.gz"
docker exec ente-postgres pg_dumpall -U pguser | gzip -9 > \
  "$base/db/ente-postgres-$stamp.sql.gz"

tar -czf "$base/config/private-cloud-config-$stamp.tar.gz" \
  /etc/caddy/Caddyfile \
  /etc/crypttab \
  /etc/fstab \
  /etc/private-cloud \
  /srv/secure/nextcloud/compose.yaml \
  /srv/secure/nextcloud/.env \
  /srv/secure/ente/compose.yaml \
  /srv/secure/ente/.env \
  /srv/secure/ente/museum.yaml

find "$base/db" -type f -name '*.sql.gz' -mtime +14 -delete
find "$base/config" -type f -name '*.tar.gz' -mtime +30 -delete
find "$base/logs" -type f -name '*.log' -mtime +30 -delete
EOF

sudo chmod 700 /usr/local/sbin/private-cloud-backup

Create the systemd service and timer.

sudo tee /etc/systemd/system/private-cloud-backup.service >/dev/null <<'EOF'
[Unit]
Description=Back up private cloud databases and configuration
Requires=docker.service srv-secure.mount
After=docker.service srv-secure.mount

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/private-cloud-backup
EOF

sudo tee /etc/systemd/system/private-cloud-backup.timer >/dev/null <<'EOF'
[Unit]
Description=Daily private cloud backup

[Timer]
OnCalendar=*-*-* 03:15:00
RandomizedDelaySec=20min
Persistent=true

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now private-cloud-backup.timer
sudo systemctl start private-cloud-backup.service
sudo systemctl status private-cloud-backup.service --no-pager

Copy encrypted backups off the Pi. For Ente, back up PostgreSQL, MinIO data, and museum.yaml together. Ente object storage alone is not enough to restore accounts.

16. Schedule Monthly Btrfs Scrub

Btrfs scrub checks stored data and metadata for disk errors.

sudo tee /etc/systemd/system/private-cloud-scrub.service >/dev/null <<'EOF'
[Unit]
Description=Scrub private cloud Btrfs pool

[Service]
Type=oneshot
ExecStart=/usr/bin/btrfs scrub start -Bd /srv/secure
EOF

sudo tee /etc/systemd/system/private-cloud-scrub.timer >/dev/null <<'EOF'
[Unit]
Description=Monthly private cloud Btrfs scrub

[Timer]
OnCalendar=monthly
RandomizedDelaySec=2h
Persistent=true

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload
sudo systemctl enable --now private-cloud-scrub.timer

17. Final Verification

Run these checks.

findmnt /srv/secure
sudo cryptsetup status secure_a
sudo cryptsetup status secure_b
sudo btrfs filesystem usage /srv/secure

sudo docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'
sudo ss -lntup
sudo ufw status verbose
sudo systemctl --no-pager --full status caddy docker
sudo systemctl list-timers --all | grep -E 'nextcloud|private-cloud'

sudo docker exec -u www-data nextcloud php occ status
curl -fsS "https://ente-api.$DOMAIN/ping"

Confirm this before relying on the setup:

  • Only ports 22, 80, and 443 are publicly reachable.
  • App container ports bind to 127.0.0.1.
  • Cloudflare records are DNS-only.
  • Nextcloud opens at https://nextcloud.<DOMAIN>.
  • Ente Photos opens at https://photos.<DOMAIN>.
  • Ente mobile apps use https://ente-api.<DOMAIN> as the custom server.
  • Nextcloud family users have 200 GB quota.
  • Ente family users have 200 GB quota.
  • Admin/owner accounts are unlimited.
  • Every account has a unique password and 2FA if needed.
  • Nextcloud E2EE recovery mnemonic is stored offline.
  • Ente recovery keys are stored offline.
  • Backups have been copied off the Pi and test-restored.

18. Routine Maintenance

Back up databases before updates.

sudo systemctl start private-cloud-backup.service

sudo apt update
sudo apt upgrade -y

cd /srv/secure/nextcloud
sudo docker compose pull
sudo docker compose up -d
sudo docker exec -u www-data nextcloud php occ upgrade
sudo docker exec -u www-data nextcloud php occ maintenance:repair

cd /srv/secure/ente
sudo docker compose pull
sudo docker compose up -d

Check health regularly.

sudo journalctl -u caddy --since today
sudo journalctl -u nextcloud-cron.service --since today
sudo docker logs --since 24h nextcloud
cd /srv/secure/ente && sudo docker compose logs --since 24h
sudo smartctl -a "$DISK_A"
sudo smartctl -a "$DISK_B"
sudo btrfs device stats /srv/secure
df -h /srv/secure

19. Talk High-Performance Calls and Optional E2EE

Nextcloud Talk call E2EE requires the high-performance backend (HPB). Run the installer after replacing both example values:

cd ~/private-cloud-setup
sudo ./scripts/setup-talk-hpb.sh domain.com home-name.ddns.net

The router must forward TCP and UDP 3478 to the Pi. The Cloudflare record for talk.domain.com must remain DNS only. Verify the backend:

docker inspect -f '{{.State.Health.Status}}' nextcloud-talk-hpb
curl -fsS https://talk.domain.com/standalone-signaling/api/v1/welcome
docker exec -u www-data nextcloud php occ talk:signaling:verify-keys

Enable end-to-end encryption for call audio and video:

docker exec -u www-data nextcloud php occ config:app:set \
  spreed call_end_to_end_encryption --type=boolean --value=true
docker exec -u www-data nextcloud php occ config:app:get \
  spreed call_end_to_end_encryption

Restart the Talk app on every phone and computer. Test one-to-one and group calls using every client type. If any client reports that calls are unsupported, disable call E2EE:

docker exec -u www-data nextcloud php occ config:app:set \
  spreed call_end_to_end_encryption --type=boolean --value=false
docker exec -u www-data nextcloud php occ config:app:get \
  spreed call_end_to_end_encryption

The returned value is 1 when enabled and 0 when disabled. This setting encrypts call media; it does not add end-to-end encryption to Talk chat messages. Keep it disabled unless every user's Talk clients support it. See the official Talk setting reference.

Quick Troubleshooting

HTTPS Does Not Work

Check DNS and router forwarding first.

getent hosts "nextcloud.$DOMAIN"
curl -I "http://nextcloud.$DOMAIN"
sudo journalctl -u caddy --since "30 minutes ago"

Nextcloud Says Untrusted Domain

Reapply the trusted domain.

sudo docker exec -u www-data nextcloud php occ config:system:set trusted_domains 1 \
  --value="nextcloud.$DOMAIN"

Ente Uploads Fail

Check Museum, MinIO, and Caddy logs.

cd /srv/secure/ente
sudo docker compose logs --since 30m museum minio
curl -fsS "https://ente-api.$DOMAIN/ping"

Storage Is Not Mounted After Reboot

Unlock the drives locally, then mount the pool.

sudo cryptsetup open "$DISK_A" secure_a
sudo cryptsetup open "$DISK_B" secure_b
sudo mount /srv/secure
findmnt /srv/secure

Conclusion

This Raspberry Pi self-hosted cloud setup gives you private file sync with Nextcloud, encrypted photo backup with Ente Photos, HTTPS with Caddy, DNS through Cloudflare and No-IP, encrypted Btrfs storage, automated backups, and practical security hardening. It is built for home users who want a private Google Drive and Google Photos alternative on their own hardware.