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
80to<PI_LAN_IP>:80. - Forward public TCP
443to<PI_LAN_IP>:443. - Do not forward PostgreSQL, MinIO, Nextcloud
8080, Ente8082, 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
dockergroup 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, and443are 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.