Google Photos was great until it wasn't. The free tier died years ago, my "Storage almost full" notification had become a weekly ritual, and the idea of paying rent forever to access my own memories started to genuinely annoy me. So last weekend I did what any reasonable person with a spare ARM board and trust issues would do: I built my own Google Photos.
The whole thing runs on a 4-core ARM board with 8 GB of RAM sitting in my closet. Photos auto-upload from my phone, I get a timeline view, a map, sharing links, the works. Total cost of ownership: the electricity a 5-watt board draws. Here's exactly how I did it — the native way and the Docker way — with the mistakes included, because that's where the learning is.
Too long; just run it: the whole native setup is also packaged as an interactive script — it asks you a handful of questions (domain, nginx or apache, postgres or mariadb, data dir) and does the rest. Grab setup-nextcloud.sh, read it (always read scripts before piping them into your life), then chmod +x setup-nextcloud.sh && ./setup-nextcloud.sh. The post below is the "what and why" behind everything the script does.
The stack (and why)
- Nextcloud — the obvious pick. It's the only self-hosted suite with a mature Android app that does camera auto-upload properly.
-
nginx + PHP-FPM instead of Apache. Nextcloud ships
.htaccessfiles and "officially" prefers Apache, but their docs publish a full nginx config, and nginx uses a fraction of the RAM. On a small board that matters. -
PostgreSQL instead of MySQL/MariaDB. Nextcloud's own docs recommend it, file locking behaves better under concurrency, and you skip the whole
utf8mb4/ row-format dance MariaDB makes you do. - A 128 GB USB drive for data. SD cards die under database + thumbnail workloads. Don't store your photos on the boot card.
- Cloudflare Tunnel for HTTPS and remote access. No port forwarding, no exposing my home IP, free valid TLS.
Part 1: The native install
This is the path I actually ran. Everything below is copy-paste real.
Packages
sudo apt update
sudo apt install -y nginx postgresql redis-server \
php-fpm php-pgsql php-gd php-curl php-zip php-xml php-mbstring \
php-intl php-bcmath php-gmp php-imagick php-redis php-apcu \
unzip ffmpeg
(If you're team MySQL: swap postgresql for mariadb-server and php-pgsql for php-mysql. Everything else is identical. I'll show both database setups below.)
First gotcha, straight from my terminal history: on a freshly booted Ubuntu, unattended-upgrades holds the dpkg lock hostage for a few minutes and apt just fails. Either wait it out or:
while sudo fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do sleep 10; done
Database
PostgreSQL:
sudo -u postgres psql
CREATE USER nextcloud WITH PASSWORD 'pick-a-real-password';
CREATE DATABASE nextcloud OWNER nextcloud TEMPLATE template0 ENCODING 'UTF8';
Or MariaDB, if that's your religion:
sudo mariadb
CREATE DATABASE nextcloud CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER 'nextcloud'@'localhost' IDENTIFIED BY 'pick-a-real-password';
GRANT ALL PRIVILEGES ON nextcloud.* TO 'nextcloud'@'localhost';
FLUSH PRIVILEGES;
One thing to check before you pick a PHP version: Nextcloud is picky about it. There's a compatibility matrix on their wiki — check it before apt installs whatever your distro's default is. My Ubuntu shipped PHP 8.5, which Nextcloud 34/35 supports. Lucky. An older release would have needed a PPA dance.
The data directory (on the USB drive)
My drive already had a partition, so I just mounted it. If yours is fresh, format it first (sudo mkfs.ext4 -L nextcloud-data /dev/sda1 — check the device name three times before running that).
sudo mkdir -p /mnt/usb
sudo mount /dev/sda1 /mnt/usb
sudo mkdir /mnt/usb/nextcloud-data
sudo chown www-data:www-data /mnt/usb/nextcloud-data
sudo chmod 750 /mnt/usb/nextcloud-data
Make it survive reboots. The nofail flag is important — without it, the board won't boot if the drive is unplugged:
echo "UUID=$(sudo blkid -s UUID -o value /dev/sda1) /mnt/usb ext4 defaults,nofail 0 2" | sudo tee -a /etc/fstab
Nextcloud itself
cd /tmp
wget https://download.nextcloud.com/server/releases/latest.zip
sudo unzip -q latest.zip -d /var/www/
sudo chown -R www-data:www-data /var/www/nextcloud
PHP tuning — /etc/php/8.5/fpm/conf.d/99-nextcloud.ini (drop the same file into cli/conf.d/ too, or occ will behave differently from the web app and confuse you later):
memory_limit = 512M
upload_max_filesize = 10G
post_max_size = 10G
max_execution_time = 360
output_buffering = Off
opcache.enable = 1
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 10000
opcache.memory_consumption = 256
opcache.save_comments = 1
opcache.revalidate_freq = 60
apc.enable_cli = 1
The nginx vhost — /etc/nginx/sites-available/nextcloud. This is adapted from Nextcloud's official nginx template; the non-obvious bits are the .well-known redirects (CalDAV/CardDAV break without them) and the front_controller_active param:
upstream php-handler {
server unix:/run/php/php8.5-fpm.sock;
}
server {
listen 80;
listen [::]:80;
server_name cloud.example.com;
root /var/www/nextcloud;
server_tokens off;
client_max_body_size 10G;
client_body_timeout 360s;
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "no-referrer" always;
fastcgi_hide_header X-Powered-By;
index index.php index.html /index.php$request_uri;
location = /robots.txt { allow all; log_not_found off; access_log off; }
location ^~ /.well-known {
location = /.well-known/carddav { return 301 /remote.php/dav/; }
location = /.well-known/caldav { return 301 /remote.php/dav/; }
location /.well-known/acme-challenge { try_files $uri $uri/ =404; }
return 301 /index.php$request_uri;
}
location ~ ^/(?:build|tests|config|lib|3rdparty|templates|data)(?:$|/) { return 404; }
location ~ ^/(?:\.|autotest|occ|issue|indie|db_|console) { return 404; }
location ~ \.php(?:$|/) {
rewrite ^/(?!index|remote|public|cron|core\/ajax\/update|status|ocs\/v[12]|updater\/.+|ocs-provider\/.+|.+\/richdocumentscode(_arm64)?\/proxy) /index.php$request_uri;
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
set $path_info $fastcgi_path_info;
try_files $fastcgi_script_name =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $path_info;
fastcgi_param HTTPS off;
fastcgi_param modHeadersAvailable true;
fastcgi_param front_controller_active true;
fastcgi_pass php-handler;
fastcgi_intercept_errors on;
fastcgi_request_buffering off;
fastcgi_max_temp_file_size 0;
fastcgi_read_timeout 360s;
}
location ~ \.(?:css|js|mjs|svg|gif|png|jpg|ico|wasm|map)$ {
try_files $uri /index.php$request_uri;
add_header Cache-Control "public, max-age=15778463";
access_log off;
}
location /remote { return 301 /remote.php$request_uri; }
location / {
try_files $uri $uri/ /index.php$request_uri;
}
}
Two things that bit people before you: one, add mjs to /etc/nginx/mime.types (application/javascript js mjs;) or parts of the web UI silently break. Two, if you'd rather use Apache2 — fine, the zip ships with a working .htaccess, just a2enmod rewrite headers env dir mime and point a vhost at /var/www/nextcloud. I still think nginx is worth it.
Enable and light it up:
sudo ln -s /etc/nginx/sites-available/nextcloud /etc/nginx/sites-enabled/
sudo rm /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginx
sudo systemctl restart php8.5-fpm
The actual install
Skip the web wizard. The CLI installer is faster and scriptable:
sudo -u www-data php /var/www/nextcloud/occ maintenance:install \
--database pgsql --database-host 127.0.0.1 \
--database-name nextcloud --database-user nextcloud \
--database-pass 'pick-a-real-password' \
--admin-user admin --admin-pass 'a-better-password-than-this' \
--data-dir /mnt/usb/nextcloud-data
(--database mysql for the MariaDB crowd.)
Then the post-install config everyone forgets:
cd /var/www/nextcloud
sudo -u www-data php occ config:system:set trusted_domains 1 --value=cloud.example.com
sudo -u www-data php occ config:system:set memcache.local --value='\OC\Memcache\APCu'
sudo -u www-data php occ config:system:set memcache.locking --value='\OC\Memcache\Redis'
sudo -u www-data php occ config:system:set redis host --value=127.0.0.1
sudo -u www-data php occ config:system:set redis port --value=6379 --type=integer
sudo -u www-data php occ config:system:set default_phone_region --value=IN
sudo -u www-data php occ background:cron
And the cron job that actually runs background tasks (without this, thumbnails and cleanup silently never happen):
echo "*/5 * * * * php -f /var/www/nextcloud/cron.php" | sudo crontab -u www-data -
Sanity check: curl http://<server-ip>/status.php should return JSON with "installed":true.
Part 2: The Docker way (if you hate fun)
If the above looked like a lot — fair. Here's the same thing as one docker-compose.yml:
services:
db:
image: postgres:16
restart: always
volumes:
- db:/var/lib/postgresql/data
environment:
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: pick-a-real-password
redis:
image: redis:alpine
restart: always
app:
image: nextcloud:apache # or nextcloud:fpm + an nginx container
restart: always
ports:
- 8080:80
volumes:
- nextcloud:/var/www/html
- /mnt/usb/nextcloud-data:/var/www/html/data
environment:
POSTGRES_HOST: db
POSTGRES_DB: nextcloud
POSTGRES_USER: nextcloud
POSTGRES_PASSWORD: pick-a-real-password
REDIS_HOST: redis
NEXTCLOUD_TRUSTED_DOMAINS: cloud.example.com
depends_on:
- db
- redis
volumes:
db:
nextcloud:
docker compose up -d and you're at the same place. Swap postgres:16 for mariadb:11 if you prefer. Honest take: Docker is easier to start, but the native install is easier to understand when something breaks at 2 AM — and something always breaks at 2 AM. Pick your poison knowingly.
Part 3: HTTPS without opening ports
My ISP puts me behind NAT anyway, so port forwarding was never on the table. Cloudflare Tunnel solved it in about four commands:
wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-arm64.deb
sudo dpkg -i cloudflared-linux-arm64.deb
cloudflared tunnel login # gives you a URL, open it, authorize your domain
cloudflared tunnel create mycloud
cloudflared tunnel route dns mycloud cloud.example.com
/etc/cloudflared/config.yml:
tunnel: <tunnel-id-from-the-create-command>
credentials-file: /etc/cloudflared/<tunnel-id>.json
ingress:
- hostname: cloud.example.com
service: http://localhost:80
- service: http_status:404
sudo cloudflared service install
sudo systemctl enable --now cloudflared
Valid HTTPS, real cert, zero open inbound ports. Then tell Nextcloud about its public identity:
sudo -u www-data php occ config:system:set overwriteprotocol --value=https
sudo -u www-data php occ config:system:set overwritehost --value=cloud.example.com
sudo -u www-data php occ config:system:set overwrite.cli.url --value=https://cloud.example.com
sudo -u www-data php occ config:system:set trusted_proxies 0 --value=127.0.0.1
That last one matters — without it, every visitor in your logs shows up as 127.0.0.1 and your brute-force protection can't tell attackers apart.
Since I own the domain anyway, I also themed the login page — logo, colors, the lot. This is what greets me now:
Part 4: Locking it down
Since it's internet-facing, minimum viable paranoia:
# Firewall: LAN only for SSH and HTTP. The world only enters via the tunnel.
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 192.168.1.0/24 to any port 22 proto tcp
sudo ufw allow from 192.168.1.0/24 to any port 80 proto tcp
sudo ufw --force enable
# fail2ban for SSH and Nextcloud logins
sudo apt install -y fail2ban
The Nextcloud jail needs a custom filter because the log is JSON — /etc/fail2ban/filter.d/nextcloud.conf:
[Definition]
_groupsre = (?:(?:,?\s*"\w+":(?:"(?:\\.|[^"])+"|\w+))*)
failregex = ^\{%(_groupsre)s,?\s*"remoteAddr":"<HOST>"%(_groupsre)s,?\s*"message":"Login failed:
datepattern = ,?\s*"time"\s*:\s*"%%Y-%%m-%%dT%%H:%%M:%%S%%z"
/etc/fail2ban/jail.local:
[sshd]
enabled = true
maxretry = 4
bantime = 1h
[nextcloud]
enabled = true
port = 80,443
filter = nextcloud
logpath = /mnt/usb/nextcloud-data/nextcloud.log
maxretry = 5
bantime = 1h
Plus inside Nextcloud itself: enable the password_policy app (12+ chars, block breached passwords), turn on TOTP 2FA for your account, and enable the Suspicious Login app. Then run sudo -u www-data php occ setupchecks — it grades your homework. Keep going until it stops complaining.
Part 5: The phone part (the actual point of all this)
Install the Nextcloud Android app, type your domain, log in, and enable Auto upload for the camera folder. Then install the Memories app on the server (occ app:install memories, or from the web UI) — that's what gives you the Google-Photos-style timeline, map view, and "on this day" nostalgia hits. I also added Calendar, Contacts, Notes and Tasks; with DAVx⁵ from F-Droid, my phone's native calendar and contacts now sync to my closet instead of Mountain View.
What it looks like
All of these are from the actual box. I made a throwaway account with stock photos for the screenshots — my real library stays private, which is rather the point of the whole exercise.
Was it worth it?
Honest answer: as a file-syncing appliance, Google is still smoother. Their search is magic, their apps are polished, and nobody calls you when their server breaks at 2 AM.
But my photos live on a drive I can unplug and hold in my hand. Nobody's training anything on them. Nobody's holding them for ransom at ₹1,300 a year. And when I pull a photo from three years ago on my phone, it's served out of my closet by a board that draws less power than the LED on my TV.
Some things you do because they're practical. Some you do because they're yours.
Full configs from this build are in the post — everything ran on Ubuntu (ARM64) with Nextcloud 35, nginx 1.28, PostgreSQL 18 and Redis 8. If you build this and something explodes, the comments are open.
Appendix: the full setup script
Everything in this post, automated. Also available as a downloadable file — read it first, then chmod +x setup-nextcloud.sh && ./setup-nextcloud.sh.
#!/usr/bin/env bash
#
# setup-nextcloud.sh — self-hosted Nextcloud on bare metal, no Docker, no snap.
#
# i run this on the little ARM board in my closet, but it works on any
# ubuntu/debian-ish x86 box too. it asks you a few questions, then sets up:
#
# nginx or apache + php-fpm + postgres or mariadb + redis
# nextcloud itself, cron, caching, and (optionally) a cloudflare tunnel
# so you get real https without opening ports on your router.
#
# read it before you run it. that's just good hygiene.
#
# chmod +x setup-nextcloud.sh && ./setup-nextcloud.sh
#
set -euo pipefail
# -- pretty output ------------------------------------------------------------
c_cyan=$'\e[36m'; c_green=$'\e[32m'; c_yellow=$'\e[33m'; c_red=$'\e[31m'; c_off=$'\e[0m'
say() { echo "${c_cyan}==>${c_off} $*"; }
ok() { echo "${c_green} ok${c_off} $*"; }
warn() { echo "${c_yellow} !!${c_off} $*"; }
die() { echo "${c_red} xx${c_off} $*" >&2; exit 1; }
# -- sanity checks --------------------------------------------------------------
[[ $EUID -eq 0 ]] && die "don't run me as root directly. run as your normal user; i'll sudo when needed."
command -v apt-get >/dev/null || die "this script speaks apt. debian/ubuntu only, sorry."
sudo -v || die "need sudo."
# fresh-booted ubuntu boxes love holding the dpkg lock hostage for a bit
say "waiting for any background apt runs to finish"
while sudo fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do sleep 5; done
# -- questions ------------------------------------------------------------------
echo
say "a few questions before we start"
read -rp " public domain for the cloud (e.g. cloud.example.com, blank = lan only): " NC_DOMAIN
NC_DOMAIN=${NC_DOMAIN:-$(hostname -I | awk '{print $1}')}
read -rp " web server [nginx/apache] (default nginx): " WEB
WEB=${WEB:-nginx}
read -rp " database [postgres/mariadb] (default postgres): " DB
DB=${DB:-postgres}
read -rp " data directory (default /var/nextcloud-data): " DATA_DIR
DATA_DIR=${DATA_DIR:-/var/nextcloud-data}
read -rp " admin username (default admin): " ADMIN_USER
ADMIN_USER=${ADMIN_USER:-admin}
read -rsp " admin password: " ADMIN_PASS; echo
[[ -z "$ADMIN_PASS" ]] && die "empty password? nice try."
DB_PASS=$(openssl rand -base64 24 | tr -d '/+=' | head -c 24)
# warn gently if the data dir is about to live on the root sd card
ROOT_SRC=$(findmnt -n -o SOURCE /)
DATA_SRC=$(findmnt -n -o SOURCE -T "$DATA_DIR" 2>/dev/null || echo "$ROOT_SRC")
if [[ "$DATA_SRC" == "$ROOT_SRC" && "$ROOT_SRC" == *mmcblk* ]]; then
warn "$DATA_DIR will sit on the SD card ($ROOT_SRC)."
warn "sd cards die young under database + thumbnail workloads."
read -rp " continue anyway? [y/N]: " YOLO
[[ "${YOLO,,}" == "y" ]] || die "mount a usb drive first, point the data dir at it, rerun me."
fi
# -- packages -------------------------------------------------------------------
say "installing packages"
sudo apt-get update -qq
PHP_PKGS="php-fpm php-gd php-curl php-zip php-xml php-mbstring php-intl php-bcmath php-gmp php-imagick php-redis php-apcu"
[[ "$DB" == postgres ]] && PHP_PKGS="$PHP_PKGS php-pgsql" || PHP_PKGS="$PHP_PKGS php-mysql"
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \
redis-server unzip curl openssl ffmpeg \
$PHP_PKGS \
$( [[ "$WEB" == nginx ]] && echo nginx || echo "apache2 libapache2-mod-php" ) \
$( [[ "$DB" == postgres ]] && echo postgresql || echo mariadb-server )
PHP_VER=$(php -r 'echo PHP_MAJOR_VERSION.".".PHP_MINOR_VERSION;')
PHP_SOCK="/run/php/php${PHP_VER}-fpm.sock"
ok "php $PHP_VER, $WEB, $DB, redis — all in"
# -- database -------------------------------------------------------------------
say "setting up the database"
if [[ "$DB" == postgres ]]; then
sudo -u postgres psql -v ON_ERROR_STOP=1 <<SQL
CREATE USER nextcloud WITH PASSWORD '${DB_PASS}';
CREATE DATABASE nextcloud OWNER nextcloud TEMPLATE template0 ENCODING 'UTF8';
SQL
else
sudo mariadb <<SQL
CREATE DATABASE nextcloud CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
CREATE USER 'nextcloud'@'localhost' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON nextcloud.* TO 'nextcloud'@'localhost';
FLUSH PRIVILEGES;
SQL
fi
ok "database ready"
# -- nextcloud ------------------------------------------------------------------
say "fetching nextcloud"
wget -q https://download.nextcloud.com/server/releases/latest.zip -O /tmp/nextcloud.zip
sudo unzip -q /tmp/nextcloud.zip -d /var/www/
sudo mkdir -p "$DATA_DIR"
sudo chown -R www-data:www-data /var/www/nextcloud "$DATA_DIR"
sudo chmod 750 "$DATA_DIR"
ok "nextcloud unpacked to /var/www/nextcloud"
# -- php tuning -----------------------------------------------------------------
say "tuning php"
sudo tee /etc/php/${PHP_VER}/fpm/conf.d/99-nextcloud.ini >/dev/null <<'INI'
memory_limit = 512M
upload_max_filesize = 10G
post_max_size = 10G
max_execution_time = 360
output_buffering = Off
opcache.enable = 1
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 10000
opcache.memory_consumption = 256
opcache.save_comments = 1
opcache.revalidate_freq = 60
apc.enable_cli = 1
INI
# cli needs the same settings or occ behaves differently from the web app.
sudo cp /etc/php/${PHP_VER}/fpm/conf.d/99-nextcloud.ini /etc/php/${PHP_VER}/cli/conf.d/99-nextcloud.ini
# -- web server -----------------------------------------------------------------
say "configuring $WEB"
if [[ "$WEB" == nginx ]]; then
# .mjs missing from mime.types breaks parts of the ui. ask me how i know.
grep -q 'mjs' /etc/nginx/mime.types || \
sudo sed -i 's|application/javascript\s\+js;|application/javascript js mjs;|' /etc/nginx/mime.types
sudo tee /etc/nginx/sites-available/nextcloud >/dev/null <<NGINX
upstream php-handler {
server unix:${PHP_SOCK};
}
server {
listen 80;
listen [::]:80;
server_name ${NC_DOMAIN};
root /var/www/nextcloud;
server_tokens off;
client_max_body_size 10G;
client_body_timeout 360s;
add_header Strict-Transport-Security "max-age=15552000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "no-referrer" always;
fastcgi_hide_header X-Powered-By;
index index.php index.html /index.php\$request_uri;
location = /robots.txt { allow all; log_not_found off; access_log off; }
location ^~ /.well-known {
location = /.well-known/carddav { return 301 /remote.php/dav/; }
location = /.well-known/caldav { return 301 /remote.php/dav/; }
location /.well-known/acme-challenge { try_files \$uri \$uri/ =404; }
return 301 /index.php\$request_uri;
}
location ~ ^/(?:build|tests|config|lib|3rdparty|templates|data)(?:\$|/) { return 404; }
location ~ ^/(?:\.|autotest|occ|issue|indie|db_|console) { return 404; }
location ~ \.php(?:\$|/) {
rewrite ^/(?!index|remote|public|cron|core\/ajax\/update|status|ocs\/v[12]|updater\/.+|ocs-provider\/.+|.+\/richdocumentscode(_arm64)?\/proxy) /index.php\$request_uri;
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
set \$path_info \$fastcgi_path_info;
try_files \$fastcgi_script_name =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME \$document_root\$fastcgi_script_name;
fastcgi_param PATH_INFO \$path_info;
fastcgi_param HTTPS off;
fastcgi_param modHeadersAvailable true;
fastcgi_param front_controller_active true;
fastcgi_pass php-handler;
fastcgi_intercept_errors on;
fastcgi_request_buffering off;
fastcgi_max_temp_file_size 0;
fastcgi_read_timeout 360s;
}
location ~ \.(?:css|js|mjs|svg|gif|png|jpg|ico|wasm|map)\$ {
try_files \$uri /index.php\$request_uri;
add_header Cache-Control "public, max-age=15778463";
access_log off;
}
location /remote { return 301 /remote.php\$request_uri; }
location / {
try_files \$uri \$uri/ /index.php\$request_uri;
}
}
NGINX
sudo ln -sf /etc/nginx/sites-available/nextcloud /etc/nginx/sites-enabled/nextcloud
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t
sudo systemctl reload nginx
else
sudo a2enmod rewrite headers env dir mime ssl >/dev/null
sudo tee /etc/apache2/sites-available/nextcloud.conf >/dev/null <<APACHE
<VirtualHost *:80>
ServerName ${NC_DOMAIN}
DocumentRoot /var/www/nextcloud
<Directory /var/www/nextcloud>
Require all granted
AllowOverride All
Options FollowSymLinks MultiViews
</Directory>
</VirtualHost>
APACHE
sudo a2ensite nextcloud >/dev/null
sudo a2dissite 000-default >/dev/null 2>&1 || true
sudo systemctl reload apache2
fi
sudo systemctl restart "php${PHP_VER}-fpm"
ok "$WEB is serving"
# -- install --------------------------------------------------------------------
say "running the nextcloud installer"
if [[ "$DB" == postgres ]]; then
DBARGS=(--database pgsql --database-host 127.0.0.1)
else
DBARGS=(--database mysql)
fi
sudo -u www-data php /var/www/nextcloud/occ maintenance:install \
"${DBARGS[@]}" \
--database-name nextcloud --database-user nextcloud --database-pass "$DB_PASS" \
--admin-user "$ADMIN_USER" --admin-pass "$ADMIN_PASS" \
--data-dir "$DATA_DIR"
ok "installed"
# -- config ---------------------------------------------------------------------
say "wiring up cache, cron, trusted domains"
occ() { sudo -u www-data php /var/www/nextcloud/occ "$@"; }
occ config:system:set trusted_domains 1 --value="$NC_DOMAIN"
occ config:system:set memcache.local --value='\OC\Memcache\APCu'
occ config:system:set memcache.locking --value='\OC\Memcache\Redis'
occ config:system:set redis host --value=127.0.0.1
occ config:system:set redis port --value=6379 --type=integer
occ config:system:set default_phone_region --value=IN
occ background:cron
echo "*/5 * * * * php -f /var/www/nextcloud/cron.php" | sudo crontab -u www-data -
# -- optional: cloudflare tunnel --------------------------------------------------
echo
read -rp " set up a cloudflare tunnel for https + remote access? [y/N]: " TUNNEL
if [[ "${TUNNEL,,}" == "y" ]]; then
say "installing cloudflared"
ARCH=$(dpkg --print-architecture)
wget -q "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${ARCH}.deb" -O /tmp/cloudflared.deb
sudo dpkg -i /tmp/cloudflared.deb
warn "this next step prints a url — open it in your browser and authorize the domain."
read -rp " press enter when ready" _
cloudflared tunnel login
cloudflared tunnel create nextcloud
cloudflared tunnel route dns nextcloud "$NC_DOMAIN"
TUNNEL_ID=$(cloudflared tunnel list 2>/dev/null | awk '/nextcloud/ {print $1}' | head -1)
sudo mkdir -p /etc/cloudflared
sudo cp "$HOME/.cloudflared/${TUNNEL_ID}.json" /etc/cloudflared/
sudo chmod 600 /etc/cloudflared/"${TUNNEL_ID}".json
sudo tee /etc/cloudflared/config.yml >/dev/null <<YAML
tunnel: ${TUNNEL_ID}
credentials-file: /etc/cloudflared/${TUNNEL_ID}.json
ingress:
- hostname: ${NC_DOMAIN}
service: http://localhost:80
- service: http_status:404
YAML
sudo cloudflared service install
sudo systemctl enable --now cloudflared
occ config:system:set overwriteprotocol --value=https
occ config:system:set overwritehost --value="$NC_DOMAIN"
occ config:system:set overwrite.cli.url --value="https://${NC_DOMAIN}"
# without this every visitor looks like 127.0.0.1 and brute-force protection goes blind
occ config:system:set trusted_proxies 0 --value=127.0.0.1
ok "tunnel live — https://${NC_DOMAIN}"
fi
# -- optional: hardening ----------------------------------------------------------
echo
read -rp " add firewall + fail2ban hardening? [Y/n]: " HARDEN
if [[ "${HARDEN,,}" != "n" ]]; then
say "hardening"
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq ufw fail2ban
MY_NET=$(ip -4 route show default | head -1 | awk '{print $3}' | sed 's/\.[0-9]*$/.0\/24/')
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from "$MY_NET" to any port 22 proto tcp
sudo ufw allow from "$MY_NET" to any port 80 proto tcp
sudo ufw --force enable
# nextcloud logs failed logins as json; stock fail2ban has no filter for that
sudo tee /etc/fail2ban/filter.d/nextcloud.conf >/dev/null <<'F2B'
[Definition]
_groupsre = (?:(?:,?\s*"\w+":(?:"(?:\\.|[^"])+"|\w+))*)
failregex = ^\{%(_groupsre)s,?\s*"remoteAddr":"<HOST>"%(_groupsre)s,?\s*"message":"Login failed:
datepattern = ,?\s*"time"\s*:\s*"%%Y-%%m-%%dT%%H:%%M:%%S%%z"
F2B
sudo tee /etc/fail2ban/jail.local >/dev/null <<JAIL
[sshd]
enabled = true
maxretry = 4
bantime = 1h
[nextcloud]
enabled = true
port = 80,443
filter = nextcloud
logpath = ${DATA_DIR}/nextcloud.log
maxretry = 5
bantime = 1h
JAIL
sudo systemctl enable --now fail2ban
sudo systemctl restart fail2ban
occ app:enable password_policy >/dev/null 2>&1 || true
occ config:app:set password_policy minLength --value=12
occ config:app:set password_policy enforceUpperLowerCase --value=1
occ config:app:set password_policy enforceNumericCharacters --value=1
occ config:app:set password_policy enforceSpecialCharacters --value=1
occ config:app:set password_policy enforceNonCommonPassword --value=1
ok "ufw + fail2ban + password policy active"
fi
# -- done -------------------------------------------------------------------------
echo
say "done. here's the summary:"
echo " url: $([[ "${TUNNEL,,}" == "y" ]] && echo "https://${NC_DOMAIN}" || echo "http://${NC_DOMAIN}")"
echo " admin: ${ADMIN_USER} (password = the one you typed)"
echo " data: ${DATA_DIR}"
echo " db: ${DB} (user nextcloud, pass ${DB_PASS} — also in /var/www/nextcloud/config/config.php)"
echo
echo " phone app: install 'Nextcloud' from the play store / f-droid,"
echo " type the url above, log in, enable auto upload. that's it."
echo
echo " last thing — run this and fix whatever it flags:"
echo " sudo -u www-data php /var/www/nextcloud/occ setupchecks"
echo
Originally published on CLI-NERDS — practical Linux, DevOps, and self-hosting guides with free browser-based developer tools.






Top comments (0)