DEV Community

Cover image for I Replaced Google Photos With a $60 Board in My Closet
FlatlinerAtheist | CLI-NERDS
FlatlinerAtheist | CLI-NERDS

Posted on Originally published at clinerds.com

I Replaced Google Photos With a $60 Board in My Closet

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.

Hero — the CLINERDS cloud

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 .htaccess files 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
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

Database

PostgreSQL:

sudo -u postgres psql
CREATE USER nextcloud WITH PASSWORD 'pick-a-real-password';
CREATE DATABASE nextcloud OWNER nextcloud TEMPLATE template0 ENCODING 'UTF8';
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(--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
Enter fullscreen mode Exit fullscreen mode

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 -
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

/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
Enter fullscreen mode Exit fullscreen mode
sudo cloudflared service install
sudo systemctl enable --now cloudflared
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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:

Themed login page

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

/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
Enter fullscreen mode Exit fullscreen mode

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.

Dashboard — dark theme, recommended files

Files — folders and uploads

Photos app — timeline grid

Memories — the Google Photos-style timeline

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
Enter fullscreen mode Exit fullscreen mode

Originally published on CLI-NERDS — practical Linux, DevOps, and self-hosting guides with free browser-based developer tools.

Top comments (0)