Can I self-host Dropbox?
YES, BUT · ONE WEEKEND— setup effort 3 of 4YES, BUT — it's called Nextcloud. It takes one prompt, a 2048 MB VPS, and about 300 minutes. That is $11.99 a month you stop paying Dropbox — $143.88 a year on the Plus plan.
Why people pay for Dropbox
Stated as the vendor would want it stated. A replacement you pick without knowing what the subscription actually buys is a replacement you abandon in a fortnight.
Dropbox sells the part of a file server that is genuinely hard: a link you hand to somebody in another country that opens in two seconds, on bandwidth you do not pay for, from a machine that has never been down while you were asleep. The sync client is old enough to be boring, and for the folder that holds your work that is the highest compliment there is.
| Plan | List price | What it buys |
|---|---|---|
| Basic | free | Free, 2 GB, one person. |
| Plusthe plan this page prices against | $11.99/mo | 2 TB for one person. Roughly $9.99 a month on the annual plan. The vendor page read CA$12.99 a month from our location. |
| Standard | $18/mo per seat | Team plan, storage pooled and starting at 3 TB, one user or more. Roughly $15 per user per month on the annual plan. The vendor page read CA$21 per user per month from our location. |
| Advanced | $30/mo per seat | Pooled storage starting at 15 TB, three users minimum. Roughly $24 per user per month on the annual plan. The vendor page read CA$33 per user per month from our location. |
| Enterprise | quote only | Quote only. The page says contact sales for pricing. |
Vendor list prices in USD, read from the pricing page on 2026-08-05 · confidence: low
Replaced by Nextcloud
One project, named before the prompt, so you know what you are about to install.
Files, calendars and contacts on hardware you control, with the desktop and mobile clients pointed at it instead of somebody else's cloud.
The only replacement here that keeps the whole shape of what you were paying for: a folder that syncs both ways through official desktop and mobile clients, plus share links, versions, and calendars and contacts your phone can subscribe to. What you take on is the operating: four containers, a database dump and a file tree that have to be backed up at the same moment, and a major upgrade about twice a year that will not let you skip a version.
The swap
You'd run
Nextcloud
ONE WEEKEND · ~300 min to running · 2048 MB RAM
Dropbox Plus · vendor list price · checked 2026-08-05 · source · confidence: low
Before you start
- RAM floor
- 2048 MBfloor from upstream docs — not measured by us yet
- Disk
- 10 GBthe app, its data, and room for one backup
- Domain needed
- yes, one A recorda hostname pointed at the box before you start — TLS needs it on the cloud path, and the local path needs none
- Time budget
- ~300 min3–24 hours, through the first backup
The prompt
Two paths to the same Nextcloud: the cloud one assumes Prompt Zero is done on a server you rent, the local one assumes nothing but a computer that can run Docker Desktop. Read whichever you pick before you paste it, which is the whole reason both are on the page instead of behind a download.
Where it runs
343 lines · 14,997 bytes
What this prompt will do
- Preflight
- Layout
- Secrets
- compose.yml
- Caddy and TLS
- Firewall
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's machine. The user has completed Prompt Zero: `ssh vps` works,
Docker and Caddy are installed, the firewall is default-deny.
Run every command in this prompt on the server over `ssh vps` unless the step says otherwise.
Install Nextcloud 34.0.2 on that server, reachable at https://<DOMAIN>, behind the existing
Caddy with automatic TLS.
## 1. Preflight
If `<DOMAIN>` is still literal, ask the user for the hostname once and stop until they answer.
Its A record must already point at this server, and Nextcloud reads its trusted-domain list
only at first install, so changing the hostname later is an occ command.
Nextcloud with its database needs 2048 MB of RAM available and 10 GB free on /srv. Upstream
asks 512 MB per PHP process alone; the rest is MariaDB, Redis and restart headroom. All three
images publish amd64 and arm64.
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
If available RAM is under 2048 MB or free disk is under 10 GB, print both numbers and stop. Do
not install and hope. If `dig +short` prints nothing, print that and stop: Caddy cannot get a
certificate for a name that does not resolve.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/nextcloud /srv/nextcloud/backups
sudo install -d -m 750 /srv/nextcloud/html
sudo install -d -m 700 /srv/nextcloud/mariadb
ls -la /srv/nextcloud
```
Assert: `backups` owned by the login user, `html` at mode `750` and `mariadb` at mode `700`,
both owned by root. Leave those two alone: the Nextcloud image copies 600 MB of PHP into `html`
and chowns it to www-data, MariaDB chowns its own data directory, and both refuse a directory
you claimed first.
## 3. Secrets
Three secrets: the `nextcloud` database password, the MariaDB root password, and the first
administrator's password. Generate all three on the server. Do not print any of them, do not
repeat them in your summary, do not put them in a log line.
```bash
umask 077
cat > /srv/nextcloud/.env <<EOF
NEXTCLOUD_TRUSTED_DOMAINS=<DOMAIN>
OVERWRITECLIURL=https://<DOMAIN>
NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=$(openssl rand -hex 24)
MYSQL_PASSWORD=$(openssl rand -hex 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/nextcloud/.env
umask 022
ls -l /srv/nextcloud/.env
```
Assert: mode `-rw-------`. The administrator is named `admin` because a Nextcloud account
cannot be renamed later; step 7 gives the command that reads the password back.
## 4. compose.yml
```bash
cat > /srv/nextcloud/compose.yml <<'EOF'
# Nextcloud · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# image README ..... https://github.com/nextcloud/docker
# requirements ..... https://docs.nextcloud.com/server/34/admin_manual/installation/system_requirements.html
# reverse proxy .... https://docs.nextcloud.com/server/34/admin_manual/configuration_server/reverse_proxy_configuration.html
#
# Four services. `app` is Apache with PHP; `cron` is the same image with its
# entrypoint replaced by the /cron.sh it ships, sharing a volume because the
# jobs must see the tree the web process writes. MariaDB 11.8 is what the
# Nextcloud 34 requirements page recommends; Redis holds the file lock. Every
# ${...} comes from /srv/nextcloud/.env, mode 600. Digests read 2026-08-05.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
container_name: nextcloud-db
restart: unless-stopped
command: --transaction-isolation=READ-COMMITTED
environment:
MARIADB_DATABASE: nextcloud
MARIADB_USER: nextcloud
MARIADB_PASSWORD: ${MYSQL_PASSWORD}
MARIADB_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
MARIADB_DISABLE_UPGRADE_BACKUP: "1"
volumes:
- /srv/nextcloud/mariadb:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
retries: 20
# No `ports:` at all: 3306 is reachable only from the other containers.
redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: nextcloud-redis
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
# No `ports:` and no volume: locks and cache, on a private network.
app:
image: nextcloud:34.0.2-apache@sha256:d7666d54d87c58d52869ddda36d1acbd4a7f53faf8ab6b91293daf204f3434e8
container_name: nextcloud-app
restart: unless-stopped
environment:
MYSQL_HOST: db
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
REDIS_HOST: redis
# Present before the first launch, these three make the entrypoint
# install Nextcloud itself, so no setup wizard ever sits open.
NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER}
NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD}
NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_TRUSTED_DOMAINS}
# Caddy terminates TLS and speaks plain http here. Without these,
# every link Nextcloud builds points at http and login loops.
OVERWRITEPROTOCOL: https
OVERWRITECLIURL: ${OVERWRITECLIURL}
# The only client address this container sees is Docker's bridge
# gateway. Trust it and the visitor arrives in X-Forwarded-For.
TRUSTED_PROXIES: 172.16.0.0/12
volumes:
# NOTE: the `volumes` of `app` and `cron` have to match.
- /srv/nextcloud/html:/var/www/html
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8099.
- "127.0.0.1:8099:80"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
cron:
image: nextcloud:34.0.2-apache@sha256:d7666d54d87c58d52869ddda36d1acbd4a7f53faf8ab6b91293daf204f3434e8
container_name: nextcloud-cron
restart: unless-stopped
# /cron.sh and a crontab running cron.php every five minutes ship in
# the image; the entrypoint swap makes this copy the scheduler.
entrypoint: /cron.sh
environment:
# cron.php reads the same runtime config the web process reads: let
# these drift and jobs take another lock and build dead links.
REDIS_HOST: redis
OVERWRITEPROTOCOL: https
OVERWRITECLIURL: ${OVERWRITECLIURL}
volumes:
- /srv/nextcloud/html:/var/www/html
depends_on:
app:
condition: service_started
EOF
cd /srv/nextcloud && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Four services, one published port. Two run the same image,
one serving the site and one running the scheduler.
## 5. Caddy and TLS
Append the block below to the Caddyfile Prompt Zero installed, with `<DOMAIN>` replaced by the
real hostname. Copy the file first: a syntax error here takes down every site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-nextcloud
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Nextcloud · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.nextcloud.com/server/34/admin_manual/configuration_server/reverse_proxy_configuration.html
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also NEXTCLOUD_TRUSTED_DOMAINS in .env, which Nextcloud reads once, at first
# install. Changing it later is an occ command, not an edit here.
<DOMAIN> {
# Nextcloud sets its own X-Content-Type-Options, X-Frame-Options and
# Referrer-Policy on every response. The one header it cannot set for
# itself is HSTS, because it does not terminate the TLS, and its own
# security check asks for it by name. That is the whole list. No
# `encode`: what moves through here is compressed already.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
-Server
}
# 8099 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8099
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Assert: `caddy validate` exits 0 and the reload exits 0. If validate fails, restore
/etc/caddy/Caddyfile.before-nextcloud, reload, and report the objection. Caddy gets the
certificate on the first request and renews it itself, and speaks plain http to the container,
which is why `OVERWRITEPROTOCOL` is `https`.
## 6. Firewall
Two ports open, both Caddy's. Idempotent: on a box Prompt Zero configured they change
nothing.
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
80/tcp answers the ACME challenge and redirects to HTTPS, 443/tcp is the only way in, 443/udp
is HTTP/3. 8099 is bound to 127.0.0.1 and compose publishes neither 3306 nor 6379, so none of
the three has a host port to firewall. Assert: `Status: active`, 80, 443/tcp, 443/udp, and
nothing else.
## 7. Start and verify
The first start is slow: the entrypoint unpacks Nextcloud into /srv/nextcloud/html, waits for
MariaDB, then installs, because step 3 wrote an admin user and password first.
```bash
cd /srv/nextcloud
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/status.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/status.php
curl -sSL -o /tmp/nc-first-screen.html -w '%{http_code} %{url_effective}\n' https://<DOMAIN>/
grep -c 'id="body-login"' /tmp/nc-first-screen.html
docker compose exec -u www-data -T app php /var/www/html/occ config:system:get memcache.locking
docker compose exec -u www-data -T app php -f /var/www/html/cron.php && echo "cron OK"
```
Assert all five, printing what you received for each. The loop ends on `200`; the status
response contains `"installed":true`, `"maintenance":false` and `"versionstring":"34.0.2"`; the
third line prints `200` and a URL ending in `/login` and the grep prints `1`, which together
say the setup wizard is gone; the occ call prints `\OC\Memcache\Redis`, proof the fourth
container carries the file locks; the last prints `cron OK`. If any misses, stop, run
`docker compose logs --tail 40 app` and `docker compose logs --tail 20 db`, and name the cause:
a database that never reports healthy is step 2, a `502` that never clears is step 5, `Access
through untrusted domain` is .env and the browser disagreeing about the hostname. A running
container is not success.
The first screen at https://<DOMAIN> shows the heading `Log in to Nextcloud` above an
`Account name or email` field and a `Password` field.
STOP: tell the user to read their administrator password with
`sudo grep NEXTCLOUD_ADMIN_PASSWORD /srv/nextcloud/.env`, put it in their password manager,
then sign in at https://<DOMAIN> as `admin` and confirm the files view loads. Wait. Do not
continue until they confirm.
## 8. First backup and restore
Three artifacts: the database holds accounts, shares and the file index, the files archive
holds user data and the install config, the config archive the rest.
```bash
cd /srv/nextcloud
docker compose exec -u www-data -T app php /var/www/html/occ maintenance:mode --on
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/nextcloud/backups/nextcloud-db-$(date +%F).sql.gz
sudo tar -C /srv/nextcloud/html -czf /srv/nextcloud/backups/nextcloud-files-$(date +%F).tar.gz config data
sudo tar -czf /srv/nextcloud/backups/nextcloud-config-$(date +%F).tar.gz -C /srv/nextcloud compose.yml .env -C /etc/caddy Caddyfile
docker compose exec -u www-data -T app php /var/www/html/occ maintenance:mode --off
ls -lh /srv/nextcloud/backups/
```
Assert: three files, none empty, all three sizes printed. The site serves a maintenance page
for the length of the copy, about a minute on a fresh install. Database and files have to come
from one moment, or a restore hands users an index naming files that are not there.
A backup on the same disk as the data is not a backup. Run this from the user's machine:
```bash
mkdir -p ~/backups/nextcloud
scp vps:/srv/nextcloud/backups/* ~/backups/nextcloud/
```
To restore: `docker compose down`, `sudo rm -rf /srv/nextcloud/mariadb /srv/nextcloud/html`,
recreate both as in step 2, untar the config archive into /srv/nextcloud so .env is back before
anything starts, `docker compose up -d db`, wait for healthy, pipe `gunzip -c` on the `.sql.gz`
into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`,
untar the files archive into /srv/nextcloud/html, `docker compose up -d`. The stakes, in one
line for the user: only that database knows which file belongs to whom.
## 9. Updating later
Releases are listed at https://github.com/nextcloud/server/releases. Take all three backups
first, then edit both `image:` lines in /srv/nextcloud/compose.yml to the new tag and digest:
they are one image and move together.
```bash
cd /srv/nextcloud
docker compose pull
docker compose up -d
docker compose logs --tail 30 app
```
Nextcloud upgrades one major version at a time and refuses to start if asked to skip one, so 34
to 36 is two passes. Watch the log until it settles, then re-run step 7's check.
## 10. What will probably go wrong
The first `docker compose up -d` looks like a failed install for several minutes: Caddy answers
`502` throughout, because the container is still unpacking 600 MB of PHP into
/srv/nextcloud/html and then waiting on MariaDB. I spent that stretch certain the trusted-domain
setting was wrong. It was not. Past ten minutes of `502`, suspect the hostname: the symptom is
`Access through untrusted domain` on the page, `NEXTCLOUD_TRUSTED_DOMAINS` is read only at
first install so editing .env later does nothing. The fix: `docker compose exec -u www-data -T
app php /var/www/html/occ config:system:set trusted_domains 1 --value=<DOMAIN>`.
## 11. Out of scope
- Do not configure SMTP. On a personal install all it buys is password-reset mail, and the
user knows their own password.
- Do not install Collabora Online or ONLYOFFICE. Each is a second service with its own
container and memory floor.
- Do not use the updater in the web interface. The image is the update path, and that updater
rewrites the directory the image owns.
- Do not enable the preview, antivirus or full-text-search apps. Each turns a quiet box into
a busy one.No terminal agent? Use the chat fallback — slower, you paste the commands
For ChatGPT or Claude in a browser. The model cannot touch your server, so it hands you one command at a time and you run each one. Same install, more of your evening.
This path is slower: you paste every command yourself, and there is nobody watching the output
but you. If you can run Claude Code, use the other tab.
You are installing Nextcloud 34.0.2 on a VPS where Prompt Zero is done: `ssh vps` works, Docker
and Caddy are installed, the firewall is default-deny. Run everything over `ssh vps` unless a
step says otherwise, and replace `<DOMAIN>` with the hostname whose A record already points at
the box.
Read this before step 1. Nextcloud reads its trusted-domain list once, during the first
install, and nothing you put in `.env` afterwards changes it. Pick the hostname you intend to
keep, because changing it later is an `occ` command run inside the container rather than an
edit to a file.
## 1. Preflight
```bash
free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}'
df -BG --output=avail /srv | tail -1
dpkg --print-architecture
dig +short <DOMAIN>
```
You should see: at least `2048` MB available, at least `10` G free, `amd64` or `arm64`, and
your server's IP on the last line.
If you do not: an empty last line means the A record does not exist yet. Add it, wait a minute,
run `dig +short <DOMAIN>` again. Caddy cannot get a certificate for a hostname that does not
resolve, and failed attempts count against a rate limit you cannot see. If RAM is short, this
is the number to take seriously rather than work around: upstream asks 512 MB for a single PHP
process, and here that process shares a box with MariaDB, Redis and a second copy of the same
image running the scheduler. A 1 GB VPS will install and then fall over during your first real
upload.
## 2. Layout
```bash
sudo install -d -m 750 -o $(id -u) -g $(id -g) /srv/nextcloud /srv/nextcloud/backups
sudo install -d -m 750 /srv/nextcloud/html
sudo install -d -m 700 /srv/nextcloud/mariadb
ls -la /srv/nextcloud
```
You should see: `backups` owned by you, `html` at mode `drwxr-x---` owned by root, and
`mariadb` at mode `drwx------` owned by root.
If you do not: leave the last two owned by root on purpose. The Nextcloud image copies about
600 MB of PHP into `html` on first start and chowns it to www-data itself, and the MariaDB
image chowns its own data directory. A directory you have already chowned to yourself is one
either of them can refuse.
## 3. Secrets
Three secrets: the password for the `nextcloud` database user, the MariaDB root password, and
the password of the first administrator account. All three are generated here, on the server,
and all three go straight into a file only you can read.
```bash
umask 077
cat > /srv/nextcloud/.env <<EOF
NEXTCLOUD_TRUSTED_DOMAINS=<DOMAIN>
OVERWRITECLIURL=https://<DOMAIN>
NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=$(openssl rand -hex 24)
MYSQL_PASSWORD=$(openssl rand -hex 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 /srv/nextcloud/.env
umask 022
ls -l /srv/nextcloud/.env
```
You should see: mode `-rw-------`, your own username twice, and the path. Replace `<DOMAIN>` on
the first two lines with your real hostname before you paste.
If you do not: a mode of `-rw-r--r--` means `umask 077` did not take effect, which happens if
you pasted the lines separately in different shells. Run `chmod 600 /srv/nextcloud/.env` and
carry on. If the file already existed from an earlier attempt, this block has now overwritten
all three secrets, which is fine before the database exists and a problem afterwards: MariaDB
keeps the password it was created with, so a changed `MYSQL_PASSWORD` against an existing
`/srv/nextcloud/mariadb` produces an access-denied line in the Nextcloud log rather than
anything that mentions passwords.
Do not paste that file, any of the three secrets, or any command output containing them into
this chat window. The account name is `admin` and it cannot be changed later, so the only thing
you need to keep is the password, and step 7 tells you how to read it.
## 4. compose.yml
Paste the whole block at once, including the last two lines.
```bash
cat > /srv/nextcloud/compose.yml <<'EOF'
# Nextcloud · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# image README ..... https://github.com/nextcloud/docker
# requirements ..... https://docs.nextcloud.com/server/34/admin_manual/installation/system_requirements.html
# reverse proxy .... https://docs.nextcloud.com/server/34/admin_manual/configuration_server/reverse_proxy_configuration.html
#
# Four services. `app` is Apache with PHP; `cron` is the same image with its
# entrypoint replaced by the /cron.sh it ships, sharing a volume because the
# jobs must see the tree the web process writes. MariaDB 11.8 is what the
# Nextcloud 34 requirements page recommends; Redis holds the file lock. Every
# ${...} comes from /srv/nextcloud/.env, mode 600. Digests read 2026-08-05.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
container_name: nextcloud-db
restart: unless-stopped
command: --transaction-isolation=READ-COMMITTED
environment:
MARIADB_DATABASE: nextcloud
MARIADB_USER: nextcloud
MARIADB_PASSWORD: ${MYSQL_PASSWORD}
MARIADB_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
MARIADB_DISABLE_UPGRADE_BACKUP: "1"
volumes:
- /srv/nextcloud/mariadb:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
retries: 20
# No `ports:` at all: 3306 is reachable only from the other containers.
redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: nextcloud-redis
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
# No `ports:` and no volume: locks and cache, on a private network.
app:
image: nextcloud:34.0.2-apache@sha256:d7666d54d87c58d52869ddda36d1acbd4a7f53faf8ab6b91293daf204f3434e8
container_name: nextcloud-app
restart: unless-stopped
environment:
MYSQL_HOST: db
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
REDIS_HOST: redis
# Present before the first launch, these three make the entrypoint
# install Nextcloud itself, so no setup wizard ever sits open.
NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER}
NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD}
NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_TRUSTED_DOMAINS}
# Caddy terminates TLS and speaks plain http here. Without these,
# every link Nextcloud builds points at http and login loops.
OVERWRITEPROTOCOL: https
OVERWRITECLIURL: ${OVERWRITECLIURL}
# The only client address this container sees is Docker's bridge
# gateway. Trust it and the visitor arrives in X-Forwarded-For.
TRUSTED_PROXIES: 172.16.0.0/12
volumes:
# NOTE: the `volumes` of `app` and `cron` have to match.
- /srv/nextcloud/html:/var/www/html
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8099.
- "127.0.0.1:8099:80"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
cron:
image: nextcloud:34.0.2-apache@sha256:d7666d54d87c58d52869ddda36d1acbd4a7f53faf8ab6b91293daf204f3434e8
container_name: nextcloud-cron
restart: unless-stopped
# /cron.sh and a crontab running cron.php every five minutes ship in
# the image; the entrypoint swap makes this copy the scheduler.
entrypoint: /cron.sh
environment:
# cron.php reads the same runtime config the web process reads: let
# these drift and jobs take another lock and build dead links.
REDIS_HOST: redis
OVERWRITEPROTOCOL: https
OVERWRITECLIURL: ${OVERWRITECLIURL}
volumes:
- /srv/nextcloud/html:/var/www/html
depends_on:
app:
condition: service_started
EOF
cd /srv/nextcloud && docker compose config >/dev/null && echo "compose OK"
```
You should see: `compose OK` and nothing else.
If you do not: `env file /srv/nextcloud/.env not found` means step 3 did not write the file.
`services must be a mapping` means the indentation was lost between the page and your terminal:
run `rm /srv/nextcloud/compose.yml` and paste again in one go. A warning that
`MYSQL_PASSWORD` is not set means you are not in /srv/nextcloud, which is where the `.env`
compose reads has to be.
## 5. Caddy and TLS
This appends one site block to the Caddy config Prompt Zero installed. Replace `<DOMAIN>` in
the block with your hostname before you paste. The first line takes a copy, because a syntax
error here takes down every other site on the box.
```bash
sudo cp /etc/caddy/Caddyfile /etc/caddy/Caddyfile.before-nextcloud
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sudo tee -a /etc/caddy/Caddyfile >/dev/null <<'EOF'
# Nextcloud · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.nextcloud.com/server/34/admin_manual/configuration_server/reverse_proxy_configuration.html
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also NEXTCLOUD_TRUSTED_DOMAINS in .env, which Nextcloud reads once, at first
# install. Changing it later is an occ command, not an edit here.
<DOMAIN> {
# Nextcloud sets its own X-Content-Type-Options, X-Frame-Options and
# Referrer-Policy on every response. The one header it cannot set for
# itself is HSTS, because it does not terminate the TLS, and its own
# security check asks for it by name. That is the whole list. No
# `encode`: what moves through here is compressed already.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
-Server
}
# 8099 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8099
}
EOF
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
You should see: `Valid configuration` from validate, and no output at all from reload.
If you do not: run `sudo cp /etc/caddy/Caddyfile.before-nextcloud /etc/caddy/Caddyfile`, reload,
and paste again. Caddy terminates the TLS and speaks plain http to the container, which is why
`OVERWRITEPROTOCOL` is `https` in the compose file: without it Nextcloud builds `http://` links
and redirects for a service that is only reachable over https, and the login page bounces you
in a loop that looks like a wrong password.
## 6. Firewall
```bash
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
```
You should see: `Status: active`, rules for `80/tcp`, `443/tcp` and `443/udp`, and no rule
mentioning `8099`, `3306` or `6379`.
If you do not: delete anything for those three with `sudo ufw delete allow 8099`. 8099 is bound
to 127.0.0.1 by the compose file, and 3306 and 6379 are never published at all, so neither the
database nor the cache has a host port a firewall rule could apply to. 80/tcp is there to
answer the ACME challenge and redirect to HTTPS, 443/tcp is the only way in, and 443/udp is
HTTP/3, which Caddy offers by default. `Status: inactive` is a different problem: Prompt Zero
left this firewall enabled, so something has turned it off since, and `sudo ufw enable` puts it
back before you go any further.
## 7. Start and verify
The first start is slow. The entrypoint unpacks its copy of Nextcloud into /srv/nextcloud/html,
waits for MariaDB, and then runs the install itself, because step 3 wrote an admin user and
password before the first launch. Expect several minutes of `502` while that happens.
```bash
cd /srv/nextcloud
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' https://<DOMAIN>/status.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS https://<DOMAIN>/status.php
curl -sSL -o /tmp/nc-first-screen.html -w '%{http_code} %{url_effective}\n' https://<DOMAIN>/
grep -c 'id="body-login"' /tmp/nc-first-screen.html
docker compose exec -u www-data -T app php /var/www/html/occ config:system:get memcache.locking
docker compose exec -u www-data -T app php -f /var/www/html/cron.php && echo "cron OK"
```
You should see, in order: the loop climbing through `502` and ending on `200`; a JSON object
containing `"installed":true`, `"maintenance":false` and `"versionstring":"34.0.2"`; then `200`
and a URL ending in `/login`; then `1`; then `\OC\Memcache\Redis`; then `cron OK`.
If you do not: the `1` from the grep is the one worth understanding. It says the page a visitor
lands on is the login page rather than the setup wizard, which means nobody can walk up to your
hostname and claim the administrator account. A `0` there with `"installed":false` in the
status output means the install did not run, and the cause is almost always a typo in `.env`
that left one of the database variables empty. If the loop never leaves `502`, run
`docker compose logs --tail 40 app`: `Initializing nextcloud` means it is still unpacking and
you should wait, and repeated `Retrying install` means MariaDB has not come up, so read
`docker compose logs --tail 20 db` next. If the page body says `Access through untrusted
domain`, the hostname in `.env` is not the one you typed in the browser.
The first screen at https://<DOMAIN> shows the heading `Log in to Nextcloud` above an
`Account name or email` field and a `Password` field.
Read your password once, on the server, and put it straight into your password manager:
```bash
sudo grep NEXTCLOUD_ADMIN_PASSWORD /srv/nextcloud/.env
```
Then sign in at https://<DOMAIN> as `admin` and confirm the files view loads. Do not paste that
password, or the line that command printed, into this chat window. A running container is not
success; the login is.
## 8. First backup and restore
Three artifacts. The database holds the accounts, the shares and the file index. The files
archive holds your data and the configuration Nextcloud wrote during install. The config
archive holds what rebuilds the service around them.
```bash
cd /srv/nextcloud
docker compose exec -u www-data -T app php /var/www/html/occ maintenance:mode --on
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > /srv/nextcloud/backups/nextcloud-db-$(date +%F).sql.gz
sudo tar -C /srv/nextcloud/html -czf /srv/nextcloud/backups/nextcloud-files-$(date +%F).tar.gz config data
sudo tar -czf /srv/nextcloud/backups/nextcloud-config-$(date +%F).tar.gz -C /srv/nextcloud compose.yml .env -C /etc/caddy Caddyfile
docker compose exec -u www-data -T app php /var/www/html/occ maintenance:mode --off
ls -lh /srv/nextcloud/backups/
```
You should see: `Maintenance mode enabled`, then three files listed, the database dump a few
tens of kilobytes on a fresh install and the files archive a few megabytes, then
`Maintenance mode disabled`. The site serves a maintenance page for the minute this takes.
If you do not: a warning from `mariadb-dump` about a password on the command line is expected
and harmless, because the password came from the container's own environment and never touched
your shell. A `.sql.gz` of about 20 bytes is an empty dump, which means the command failed and
the shell created the file anyway; run the dump line without `| gzip` to read the error. If
maintenance mode is still on when you finish, run the `--off` line again, because Nextcloud
will not serve anything until you do.
A backup on the same disk as the data is not a backup. Run this one on your own machine, not
the server:
```bash
mkdir -p ~/backups/nextcloud
scp vps:/srv/nextcloud/backups/* ~/backups/nextcloud/
```
You should see: three files copied, and all three listed by `ls -lh ~/backups/nextcloud/`.
If you do not: `Permission denied (publickey)` means you ran it on the server. The `vps:` prefix
only means something on your own machine, where the `vps` alias Prompt Zero created lives.
Now prove the restore, today, while the only thing at risk is an empty account:
```bash
cd /srv/nextcloud
docker compose down
sudo rm -rf /srv/nextcloud/mariadb
sudo install -d -m 700 /srv/nextcloud/mariadb
docker compose up -d db
sleep 40
gunzip -c /srv/nextcloud/backups/nextcloud-db-$(date +%F).sql.gz | docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'
docker compose up -d
sleep 30
curl -sS https://<DOMAIN>/status.php
```
You should see: no output from the `gunzip` line, then a status object that still reads
`"installed":true`. Sign in again to be sure. That is a database deleted, rebuilt and refilled
while your files stayed where they were.
If you do not: `Access denied for user` means the database container had not finished
initialising, so wait longer and run the `gunzip` line again. `ERROR 1049 Unknown database`
means the volume was recreated without `.env` present, so check the file is still there. To
restore the files as well, untar the files archive back into /srv/nextcloud/html. Understand
what you are protecting: this is where your photos and documents live now, and the database is
the only thing that knows which file belongs to whom.
## 9. Updating later
New versions are listed at https://github.com/nextcloud/server/releases. Take all three backup
artifacts first, then edit both `image:` lines in /srv/nextcloud/compose.yml to the new tag and
its digest. They are the same image and they have to move together.
```bash
cd /srv/nextcloud
docker compose pull
docker compose up -d
docker compose logs --tail 30 app
```
You should see: `Initializing nextcloud`, then `Upgrading nextcloud from ...`, then the server
starting, and no repeating restart.
If you do not: `Can't start Nextcloud because upgrading from ... is not supported` means you
skipped a major version. Put the old tag and digest back, run the same three commands, then
step through one major version at a time. Re-run the status check from step 7 before you call
the update done.
## 10. What will probably go wrong
The first `docker compose up -d` looks like a failed install for several minutes. Caddy answers
`502` throughout, because the container is still unpacking 600 MB of PHP into
/srv/nextcloud/html and then waiting on MariaDB, and I spent that stretch certain the
trusted-domain setting was wrong. It was not. Past ten minutes of `502`, suspect the hostname:
the symptom is `Access through untrusted domain` on the page, `NEXTCLOUD_TRUSTED_DOMAINS` is
read only at first install so editing .env later does nothing, and the fix is
`docker compose exec -u www-data -T app php /var/www/html/occ config:system:set trusted_domains
1 --value=<DOMAIN>`.
## 11. Out of scope
- Do not configure SMTP. On a personal install all it buys is password-reset mail, and you know
your own password.
- Do not install Collabora Online or ONLYOFFICE. Each is a second service with its own
container and memory floor.
- Do not use the updater in the web interface. The image is the update path, and that updater
rewrites the directory the image owns.
- Do not enable the preview, antivirus or full-text-search apps. Each turns a quiet box into a
busy one.338 lines · 14,998 bytes
What this prompt will do
- Preflight
- Docker
- Layout
- Secrets
- compose.yml
- Nothing is public
- Start and verify
- First backup and restore
- Updating later
- What will probably go wrong
- Out of scope
Read out of the prompt’s own step headings at build time — if the prompt changes, this list changes with it.
You are Claude Code on the user's own computer. There is no server and no Prompt Zero:
everything in this prompt runs on this machine and stays on it.
Run every command on this computer, in the shell you are already in. Nothing in this prompt
uses ssh.
Install Nextcloud 34.0.2, with the MariaDB and Redis it needs, under ~/selfhost/nextcloud,
answering at http://localhost:8099.
## 1. Preflight
Say this to the user before step 2; it decides whether they want this install at all.
Nothing but this computer can reach the server, so the phone app and the sync client that make
Nextcloud worth running sync only from here, and only while the machine is awake.
Detect the OS and measure the machine:
```bash
uname -s
case "$(uname -s)" in
Darwin) vm_stat | awk '/page size/{p=$8} /free|inactive/{s+=$3} END {printf "%d MB available\n", s*p/1048576}' ;;
Linux) . /etc/os-release && echo "$ID $VERSION_CODENAME"; free -m | awk '/^Mem:/ {print $7 " MB available of " $2 " MB"}' ;;
MINGW*|MSYS*) powershell -Command "(Get-CimInstance Win32_OperatingSystem).FreePhysicalMemory" | awk '$1+0 {printf "%d MB available\n", $1/1024}' ;;
esac
df -h ~
```
`Darwin` is macOS, `Linux` is Linux, `MINGW` or `MSYS` is Windows under Git Bash; on Linux the
distribution ID and codename print next, for step 2. This needs 2048 MB of RAM available and
10 GB free on the home disk, and all three images publish amd64 and arm64. If RAM is under
2048 MB or free disk under 10 GB, print both numbers and stop.
## 2. Docker
Check before installing anything:
```bash
docker info >/dev/null 2>&1 && echo "docker OK" || echo "docker MISSING"
docker compose version 2>/dev/null || true
```
If that printed `docker OK` and a compose version, skip to step 3.
Otherwise, install Docker for the OS step 1 detected:
- macOS: if `command -v brew` succeeds, run `brew install --cask docker`. If there is no
Homebrew, STOP: tell the user to download Docker Desktop from
https://www.docker.com/products/docker-desktop/ and install it, and wait until they
confirm. Either way, then STOP: tell the user to open Docker Desktop once, accept its
terms, and wait for the whale icon to say it is running. Do not continue until they
confirm.
- Windows: run `winget install -e --id Docker.DockerDesktop`. If winget is missing or the
install fails, STOP: tell the user to download Docker Desktop from the URL above and
install it, and wait until they confirm. Docker Desktop configures WSL 2 itself and may
ask for a reboot; if it does, STOP and tell the user to reboot and come back, this
prompt resumes at this step. Then STOP: have the user open Docker Desktop, accept its
terms, and confirm it says running.
- Linux, Debian or Ubuntu: install Docker Engine from download.docker.com's apt
repository, with its signing key saved to a file first, never piped into a shell. The
fence is guarded, a no-op on anything but a Linux with apt:
```bash
if [ "$(uname -s)" = "Linux" ] && command -v apt-get >/dev/null 2>&1; then
sudo apt-get update
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/$(. /etc/os-release && echo "$ID")/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/$(. /etc/os-release && echo "$ID") $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
sudo usermod -aG docker "$USER"
fi
```
Adding the user to the docker group is root-equivalent on this machine; say that to the
user in one sentence, and tell them the group change lands at their next login.
- Linux, anything else: STOP. Tell the user to install Docker Engine and the compose
plugin with their distribution's package manager, and to run this prompt again once
`docker info` works.
Assert: `docker info` exits 0 and `docker compose version` prints a version. Do not
continue without both.
## 3. Layout
```bash
mkdir -p ~/selfhost/nextcloud/backups
ls -la ~/selfhost/nextcloud
```
Assert: `backups`, owned by the user. There is no `data` folder: step 5 keeps the Nextcloud
tree and the database in volumes Docker manages, because each image chowns its directory to a
uid Docker Desktop cannot grant on a bind mount.
## 4. Secrets
Three secrets: the `nextcloud` database password, the MariaDB root password, and the first
administrator's password. Generate all three here, print none, and keep them out of your
summary and any log line.
```bash
umask 077
cat > ~/selfhost/nextcloud/.env <<EOF
NEXTCLOUD_TRUSTED_DOMAINS=localhost:8099
OVERWRITECLIURL=http://localhost:8099
NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=$(openssl rand -hex 24)
MYSQL_PASSWORD=$(openssl rand -hex 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
EOF
chmod 600 ~/selfhost/nextcloud/.env
umask 022
ls -l ~/selfhost/nextcloud/.env
```
Assert: mode `-rw-------`. Git Bash ships openssl. On Windows those mode bits are advisory and
the real boundary is the user's own account. `admin` is fixed because a Nextcloud account
cannot be renamed later.
## 5. compose.yml
```bash
cat > ~/selfhost/nextcloud/compose.yml <<'EOF'
# Nextcloud · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# image README ..... https://github.com/nextcloud/docker
# requirements ..... https://docs.nextcloud.com/server/34/admin_manual/installation/system_requirements.html
#
# Four services under ~/selfhost/nextcloud/, so the paths here are relative.
# Named volumes for both data directories, because the Nextcloud image chowns
# /var/www/html to www-data and MariaDB chowns /var/lib/mysql to its own uid,
# and Docker Desktop's Windows file sharing grants neither on a bind mount.
# `cron` is the app image with entrypoint /cron.sh, mounting the same volume as
# `app`. Nothing terminates TLS: http, loopback, and localhost always trusted.
# Digests read 2026-08-05; all three images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
container_name: nextcloud-db
restart: unless-stopped
command: --transaction-isolation=READ-COMMITTED
environment:
MARIADB_DATABASE: nextcloud
MARIADB_USER: nextcloud
MARIADB_PASSWORD: ${MYSQL_PASSWORD}
MARIADB_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
MARIADB_DISABLE_UPGRADE_BACKUP: "1"
volumes:
- nextcloud-db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
retries: 20
redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: nextcloud-redis
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
app:
image: nextcloud:34.0.2-apache@sha256:d7666d54d87c58d52869ddda36d1acbd4a7f53faf8ab6b91293daf204f3434e8
container_name: nextcloud-app
restart: unless-stopped
environment:
MYSQL_HOST: db
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
REDIS_HOST: redis
NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER}
NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD}
NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_TRUSTED_DOMAINS}
OVERWRITEPROTOCOL: http
OVERWRITECLIURL: ${OVERWRITECLIURL}
volumes:
- nextcloud-html:/var/www/html
ports:
# Loopback only: no other device on the wifi can reach 8099.
- "127.0.0.1:8099:80"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
cron:
image: nextcloud:34.0.2-apache@sha256:d7666d54d87c58d52869ddda36d1acbd4a7f53faf8ab6b91293daf204f3434e8
container_name: nextcloud-cron
restart: unless-stopped
entrypoint: /cron.sh
environment:
REDIS_HOST: redis
OVERWRITEPROTOCOL: http
OVERWRITECLIURL: ${OVERWRITECLIURL}
volumes:
- nextcloud-html:/var/www/html
depends_on:
app:
condition: service_started
volumes:
nextcloud-db:
nextcloud-html:
EOF
cd ~/selfhost/nextcloud && docker compose config >/dev/null && echo "compose OK"
```
Assert: that prints `compose OK`. Four services, one published port, two named volumes.
## 6. Nothing is public
No reverse proxy, no certificate, no firewall rule. Each is a decision:
- No DNS. There is no hostname, so nothing to resolve and nothing to wait for.
- No TLS. A certificate attests a public name and nothing here has one. Browsers treat
http://localhost as a secure context, so pages needing crypto still work.
- No firewall rule. Nothing is published beyond loopback, so no port needs closing.
8099 is bound to 127.0.0.1: not the user's phone, not a laptop on the wifi, not the internet.
```bash
grep -n '127.0.0.1' ~/selfhost/nextcloud/compose.yml
```
Assert: one line, `- "127.0.0.1:8099:80"`. MariaDB and Redis publish no host port.
## 7. Start and verify
The first start is slow: the entrypoint unpacks 600 MB of Nextcloud into its volume and waits
for MariaDB before installing, because step 4 wrote the admin password first.
```bash
cd ~/selfhost/nextcloud
docker compose pull
docker compose up -d
for i in $(seq 1 60); do code=$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:8099/status.php); echo "$i $code"; [ "$code" = 200 ] && break; sleep 10; done
curl -sS http://localhost:8099/status.php
curl -sSL -o /tmp/nc-first-screen.html -w '%{http_code} %{url_effective}\n' http://localhost:8099/
grep -c 'id="body-login"' /tmp/nc-first-screen.html
docker compose exec -u www-data -T app php /var/www/html/occ config:system:get memcache.locking
docker compose exec -u www-data -T app php -f /var/www/html/cron.php && echo "cron OK"
```
Assert all five, printing what you got. The loop ends on `200`; the status response
contains `"installed":true` and `"versionstring":"34.0.2"`; the third line prints `200` and a
URL ending in `/login` and the grep prints `1`, which say the setup wizard is gone; the occ call
prints `\OC\Memcache\Redis`; the last prints `cron OK`. If any misses, stop, run
`docker compose logs --tail 40 app`, and name the cause: a database never reporting healthy
is step 4, a log still moving wants more time, `port is already allocated` means something
else holds 8099 (`lsof -nP -iTCP:8099 -sTCP:LISTEN`, or `netstat -ano | findstr :8099`). A
running container is not success.
The first screen at http://localhost:8099 shows the heading `Log in to Nextcloud` above an
`Account name or email` field and a `Password` field.
STOP: tell the user to read their password with
`grep NEXTCLOUD_ADMIN_PASSWORD ~/selfhost/nextcloud/.env`, put it in their password manager,
then sign in at http://localhost:8099 as `admin`. Wait. Do not continue until they confirm.
## 8. First backup and restore
Three artifacts: the database, the user files, and the two files that rebuild the service.
Both volumes are Docker's, so the copies come out through the containers.
```bash
cd ~/selfhost/nextcloud
docker compose exec -u www-data -T app php /var/www/html/occ maintenance:mode --on
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' | gzip > ~/selfhost/nextcloud/backups/nextcloud-db-$(date +%F).sql.gz
docker compose exec -T app tar -C /var/www/html -czf - config data > ~/selfhost/nextcloud/backups/nextcloud-files-$(date +%F).tar.gz
tar -C ~/selfhost/nextcloud -czf ~/selfhost/nextcloud/backups/nextcloud-config-$(date +%F).tar.gz compose.yml .env
docker compose exec -u www-data -T app php /var/www/html/occ maintenance:mode --off
ls -lh ~/selfhost/nextcloud/backups/
```
Assert: three files, none empty, sizes printed. Maintenance mode makes the database and the
files one moment, about a minute here.
All three sit on the same disk as the data, and on a laptop the disk and the machine fail
together. Ask the user for a destination that leaves this computer, a synced folder or a USB
stick, and copy all three there with `cp`; in Git Bash a Windows drive is `/d/Backups`. Assert:
the user confirms all three names are there. If they have none, say plainly this install has no
backup.
To restore: untar the config archive into ~/selfhost/nextcloud first, so .env is back before
any container starts and MariaDB initialises with the right password. Then
`docker compose down -v`, the one place `-v` belongs because it drops both old volumes on
purpose, `docker compose up -d db`, wait 30 seconds for healthy, and pipe `gunzip -c` on the
`.sql.gz` into
`docker compose exec -T db sh -c 'exec mariadb -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"'`.
Then `docker compose up -d`, wait for `/status.php`, and unpack the files with
`gunzip -c backups/nextcloud-files-<date>.tar.gz | docker compose exec -T app tar -C /var/www/html -xf -`.
Restart, sign in, open a file.
## 9. Updating later
Releases are at https://github.com/nextcloud/server/releases. Back up first, then edit both
`image:` lines in ~/selfhost/nextcloud/compose.yml to the new tag and digest; they are one
image and move together.
```bash
cd ~/selfhost/nextcloud
docker compose pull
docker compose up -d
docker compose logs --tail 30 app
```
Nextcloud upgrades one major version at a time and refuses to skip one. Watch the log until it
settles, then re-run step 7's check.
## 10. What will probably go wrong
Connection refused, twice, for different reasons. The first was forty seconds after
`docker compose up -d`: the container was still unpacking 600 MB of PHP into its volume and had
not started Apache. The second was after a reboot, and that one was real: Docker Desktop had
not started with the session, so nothing was listening. `restart: unless-stopped` acts only
once the Docker daemon is up. Turn on its start-at-login setting, and after a reboot run
`cd ~/selfhost/nextcloud && docker compose up -d` before concluding anything broke.
## 11. Out of scope
- Do not expose this to the internet.
- Do not configure port forwarding on the router.
- Do not add a reverse proxy or TLS.
- Do not rebind 8099 to 0.0.0.0 so a phone can reach it. That puts the user's file store on
every network they join, unencrypted.
- Do not configure SMTP. On a single-user install it only buys password-reset mail.
- Do not install Collabora Online or ONLYOFFICE, and do not enable the preview, antivirus or
full-text-search apps. Each turns a quiet laptop warm.compose.local.ymlthe services, pinned · local layout89 lines
# Nextcloud · the deterministic fallback for the local path. Authored by
# caniselfhostit from the upstream documentation, not copied from a repository:
# image README ..... https://github.com/nextcloud/docker
# requirements ..... https://docs.nextcloud.com/server/34/admin_manual/installation/system_requirements.html
#
# Four services under ~/selfhost/nextcloud/, so the paths here are relative.
# Named volumes for both data directories, because the Nextcloud image chowns
# /var/www/html to www-data and MariaDB chowns /var/lib/mysql to its own uid,
# and Docker Desktop's Windows file sharing grants neither on a bind mount.
# `cron` is the app image with entrypoint /cron.sh, mounting the same volume as
# `app`. Nothing terminates TLS: http, loopback, and localhost always trusted.
# Digests read 2026-08-05; all three images publish amd64 and arm64.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
container_name: nextcloud-db
restart: unless-stopped
command: --transaction-isolation=READ-COMMITTED
environment:
MARIADB_DATABASE: nextcloud
MARIADB_USER: nextcloud
MARIADB_PASSWORD: ${MYSQL_PASSWORD}
MARIADB_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
MARIADB_DISABLE_UPGRADE_BACKUP: "1"
volumes:
- nextcloud-db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
retries: 20
redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: nextcloud-redis
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
app:
image: nextcloud:34.0.2-apache@sha256:d7666d54d87c58d52869ddda36d1acbd4a7f53faf8ab6b91293daf204f3434e8
container_name: nextcloud-app
restart: unless-stopped
environment:
MYSQL_HOST: db
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
REDIS_HOST: redis
NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER}
NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD}
NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_TRUSTED_DOMAINS}
OVERWRITEPROTOCOL: http
OVERWRITECLIURL: ${OVERWRITECLIURL}
volumes:
- nextcloud-html:/var/www/html
ports:
# Loopback only: no other device on the wifi can reach 8099.
- "127.0.0.1:8099:80"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
cron:
image: nextcloud:34.0.2-apache@sha256:d7666d54d87c58d52869ddda36d1acbd4a7f53faf8ab6b91293daf204f3434e8
container_name: nextcloud-cron
restart: unless-stopped
entrypoint: /cron.sh
environment:
REDIS_HOST: redis
OVERWRITEPROTOCOL: http
OVERWRITECLIURL: ${OVERWRITECLIURL}
volumes:
- nextcloud-html:/var/www/html
depends_on:
app:
condition: service_started
volumes:
nextcloud-db:
nextcloud-html:agent-readable mirror: /self-host/dropbox.md
The files, if you'd rather do it yourself
The cloud path with no agent involved: three files, in the order you'd use them. The cloud prompt above writes exactly these — if the two ever disagree, the files are the ones CI diffs. The local path ships its own compose file, collapsed under its own prompt.
compose.ymlthe services, pinned98 lines
# Nextcloud · the deterministic fallback. Authored by caniselfhostit from the
# upstream documentation, not copied from a repository:
# image README ..... https://github.com/nextcloud/docker
# requirements ..... https://docs.nextcloud.com/server/34/admin_manual/installation/system_requirements.html
# reverse proxy .... https://docs.nextcloud.com/server/34/admin_manual/configuration_server/reverse_proxy_configuration.html
#
# Four services. `app` is Apache with PHP; `cron` is the same image with its
# entrypoint replaced by the /cron.sh it ships, sharing a volume because the
# jobs must see the tree the web process writes. MariaDB 11.8 is what the
# Nextcloud 34 requirements page recommends; Redis holds the file lock. Every
# ${...} comes from /srv/nextcloud/.env, mode 600. Digests read 2026-08-05.
#
# NOT YET VERIFIED: no harness run has been recorded against this file.
services:
db:
image: mariadb:11.8.8@sha256:d9f7eb2637296652f24b484afd5d246f759f49f5babcadc6a9e344c9acb75fbf
container_name: nextcloud-db
restart: unless-stopped
command: --transaction-isolation=READ-COMMITTED
environment:
MARIADB_DATABASE: nextcloud
MARIADB_USER: nextcloud
MARIADB_PASSWORD: ${MYSQL_PASSWORD}
MARIADB_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
MARIADB_AUTO_UPGRADE: "1"
MARIADB_DISABLE_UPGRADE_BACKUP: "1"
volumes:
- /srv/nextcloud/mariadb:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
start_period: 10s
interval: 10s
retries: 20
# No `ports:` at all: 3306 is reachable only from the other containers.
redis:
image: redis:8.10.0-alpine@sha256:978f0e01593e65eed801f2402944efcd936d43b5027e4908a7897baf88ed6241
container_name: nextcloud-redis
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
retries: 12
# No `ports:` and no volume: locks and cache, on a private network.
app:
image: nextcloud:34.0.2-apache@sha256:d7666d54d87c58d52869ddda36d1acbd4a7f53faf8ab6b91293daf204f3434e8
container_name: nextcloud-app
restart: unless-stopped
environment:
MYSQL_HOST: db
MYSQL_DATABASE: nextcloud
MYSQL_USER: nextcloud
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
REDIS_HOST: redis
# Present before the first launch, these three make the entrypoint
# install Nextcloud itself, so no setup wizard ever sits open.
NEXTCLOUD_ADMIN_USER: ${NEXTCLOUD_ADMIN_USER}
NEXTCLOUD_ADMIN_PASSWORD: ${NEXTCLOUD_ADMIN_PASSWORD}
NEXTCLOUD_TRUSTED_DOMAINS: ${NEXTCLOUD_TRUSTED_DOMAINS}
# Caddy terminates TLS and speaks plain http here. Without these,
# every link Nextcloud builds points at http and login loops.
OVERWRITEPROTOCOL: https
OVERWRITECLIURL: ${OVERWRITECLIURL}
# The only client address this container sees is Docker's bridge
# gateway. Trust it and the visitor arrives in X-Forwarded-For.
TRUSTED_PROXIES: 172.16.0.0/12
volumes:
# NOTE: the `volumes` of `app` and `cron` have to match.
- /srv/nextcloud/html:/var/www/html
ports:
# Loopback only: the host's Caddy is the only thing that reaches 8099.
- "127.0.0.1:8099:80"
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
cron:
image: nextcloud:34.0.2-apache@sha256:d7666d54d87c58d52869ddda36d1acbd4a7f53faf8ab6b91293daf204f3434e8
container_name: nextcloud-cron
restart: unless-stopped
# /cron.sh and a crontab running cron.php every five minutes ship in
# the image; the entrypoint swap makes this copy the scheduler.
entrypoint: /cron.sh
environment:
# cron.php reads the same runtime config the web process reads: let
# these drift and jobs take another lock and build dead links.
REDIS_HOST: redis
OVERWRITEPROTOCOL: https
OVERWRITECLIURL: ${OVERWRITECLIURL}
volumes:
- /srv/nextcloud/html:/var/www/html
depends_on:
app:
condition: service_startedCaddyfilethe hostname and TLS26 lines
# Nextcloud · the Caddy site block for this service.
#
# Authored by caniselfhostit from
# https://docs.nextcloud.com/server/34/admin_manual/configuration_server/reverse_proxy_configuration.html
# and https://caddyserver.com/docs/automatic-https
#
# Append this to /etc/caddy/Caddyfile, the Caddy that Prompt Zero installed,
# with <DOMAIN> replaced by the hostname pointed at this box. That hostname is
# also NEXTCLOUD_TRUSTED_DOMAINS in .env, which Nextcloud reads once, at first
# install. Changing it later is an occ command, not an edit here.
<DOMAIN> {
# Nextcloud sets its own X-Content-Type-Options, X-Frame-Options and
# Referrer-Policy on every response. The one header it cannot set for
# itself is HSTS, because it does not terminate the TLS, and its own
# security check asks for it by name. That is the whole list. No
# `encode`: what moves through here is compressed already.
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
-Server
}
# 8099 is the loopback port compose publishes on this host. It is not a
# container port and it is not open in the firewall.
reverse_proxy 127.0.0.1:8099
}install.shthe same install, no agent175 lines
#!/usr/bin/env bash
# Nextcloud · the agent-free install.
#
# Everything prompt.md tells an agent to do, as a script you can read first.
# Run it on the VPS, as a non-root user who is in the docker group:
#
# DOMAIN_HOST=cloud.example.com ./install.sh
#
# Authored by caniselfhostit from the upstream documentation:
# https://github.com/nextcloud/docker
# https://docs.nextcloud.com/server/34/admin_manual/installation/system_requirements.html
# https://docs.nextcloud.com/server/34/admin_manual/configuration_server/reverse_proxy_configuration.html
# https://docs.nextcloud.com/server/34/admin_manual/configuration_server/background_jobs_configuration.html
#
# Three secrets are generated here, on this machine: the database password for
# the nextcloud user, the MariaDB root password, and the password of the first
# administrator account. All three go into /srv/nextcloud/.env with mode 600 and
# none of them is ever printed.
#
# DOMAIN_HOST is also NEXTCLOUD_TRUSTED_DOMAINS, which Nextcloud reads once,
# during the first install. Changing the hostname later is an occ command.
#
# NOT YET VERIFIED: no harness run has been recorded against this script.
set -euo pipefail
APP_DIR="${APP_DIR:-/srv/nextcloud}"
DOMAIN_HOST="${DOMAIN_HOST:-}"
die() { printf 'install.sh: %s\n' "$1" >&2; exit 1; }
# --- 1. Refuse to start on a machine that is not ready -----------------------
[ -n "$DOMAIN_HOST" ] || die "set DOMAIN_HOST to the hostname you pointed at this server, e.g. cloud.example.com"
command -v docker >/dev/null 2>&1 || die "docker is not installed. Run Prompt Zero first."
docker compose version >/dev/null 2>&1 || die "the docker compose plugin is missing"
command -v caddy >/dev/null 2>&1 || die "caddy is not installed on the host. Run Prompt Zero first."
command -v openssl >/dev/null 2>&1 || die "openssl is not installed"
avail_mb="$(free -m | awk '/^Mem:/ {print $7}')"
[ "$avail_mb" -ge 2048 ] || die "only ${avail_mb} MB of RAM available; PHP, MariaDB and Redis want 2048 MB"
avail_gb="$(df -BG --output=avail /srv | tail -1 | tr -dc '0-9')"
[ "$avail_gb" -ge 10 ] || die "only ${avail_gb} GB free on /srv; this install wants 10 GB"
resolved="$(getent hosts "$DOMAIN_HOST" | awk '{print $1; exit}' || true)"
[ -n "$resolved" ] || die "$DOMAIN_HOST does not resolve yet. Add the A record, wait a minute, run this again."
# --- 2. Lay the files out ----------------------------------------------------
#
# html and mariadb stay owned by root: the Nextcloud image copies its own tree
# into html and chowns it to www-data, and MariaDB chowns its data directory.
sudo install -d -m 750 -o "$(id -u)" -g "$(id -g)" "$APP_DIR" "$APP_DIR/backups"
sudo install -d -m 750 "$APP_DIR/html"
sudo install -d -m 700 "$APP_DIR/mariadb"
install -m 0644 "$(dirname "$0")/compose.yml" "$APP_DIR/compose.yml"
install -m 0644 "$(dirname "$0")/Caddyfile" "$APP_DIR/Caddyfile"
# --- 3. Generate the three secrets, on the server ----------------------------
#
# Hex rather than base64 for all three: two travel inside a connection string
# and the third gets typed into a login form. Read the admin password later with
# sudo grep NEXTCLOUD_ADMIN_PASSWORD /srv/nextcloud/.env
if [ ! -f "$APP_DIR/.env" ]; then
umask 077
cat > "$APP_DIR/.env" <<-ENVFILE
NEXTCLOUD_TRUSTED_DOMAINS=${DOMAIN_HOST}
OVERWRITECLIURL=https://${DOMAIN_HOST}
NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=$(openssl rand -hex 24)
MYSQL_PASSWORD=$(openssl rand -hex 32)
MYSQL_ROOT_PASSWORD=$(openssl rand -hex 32)
ENVFILE
chmod 600 "$APP_DIR/.env"
umask 022
fi
cd "$APP_DIR"
docker compose config >/dev/null
# --- 4. Caddy site block, on the host ----------------------------------------
if ! sudo grep -qF "$DOMAIN_HOST {" /etc/caddy/Caddyfile; then
sudo cp /etc/caddy/Caddyfile "/etc/caddy/Caddyfile.before-nextcloud"
printf '\n' | sudo tee -a /etc/caddy/Caddyfile >/dev/null
sed "s|<DOMAIN>|${DOMAIN_HOST}|g" "$APP_DIR/Caddyfile" | sudo tee -a /etc/caddy/Caddyfile >/dev/null
fi
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
# --- 5. Ports: two open, and none of 8099, 3306 or 6379 is one of them -------
if command -v ufw >/dev/null 2>&1; then
echo "==> 80/tcp and 443/tcp for Caddy, 443/udp for HTTP/3; 8099, 3306 and 6379 stay closed"
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 443/udp
sudo ufw status verbose
fi
# --- 6. Start it -------------------------------------------------------------
#
# The first start unpacks about 600 MB of PHP into html, waits for MariaDB, and
# then runs occ maintenance:install, because step 3 wrote an admin user and
# password before this launch. Several minutes of 502 are normal.
docker compose pull
docker compose up -d
echo "==> waiting for https://${DOMAIN_HOST}/status.php"
for _ in $(seq 1 60); do
code="$(curl -sS -o /dev/null -w '%{http_code}' "https://${DOMAIN_HOST}/status.php" || true)"
[ "$code" = "200" ] && break
sleep 10
done
[ "${code:-}" = "200" ] || die "/status.php answered ${code:-nothing}. Check: docker compose logs --tail 40 app"
status="$(curl -sS "https://${DOMAIN_HOST}/status.php" || true)"
case "$status" in
*'"installed":true'*) ;;
*) die "status.php does not report installed:true. Check: docker compose logs --tail 40 app" ;;
esac
case "$status" in
*'"versionstring":"34.0.2"'*) ;;
*) die "status.php reports a version this script did not install: ${status}" ;;
esac
# The setup wizard must be gone: a visitor has to land on the login page, not on
# a form that would hand them the administrator account.
curl -sSL -o /tmp/nc-first-screen.html "https://${DOMAIN_HOST}/" || true
grep -q 'id="body-login"' /tmp/nc-first-screen.html \
|| die "https://${DOMAIN_HOST}/ is not the login page. Stop and investigate before anyone else finds it."
rm -f /tmp/nc-first-screen.html
# Redis is carrying the file locks, not sitting there idle.
locking="$(docker compose exec -u www-data -T app php /var/www/html/occ config:system:get memcache.locking | tr -d '\r\n')"
[ "$locking" = '\OC\Memcache\Redis' ] || die "memcache.locking is '${locking}', not the Redis provider"
# The scheduler path works.
docker compose exec -u www-data -T app php -f /var/www/html/cron.php >/dev/null \
|| die "cron.php failed. Background jobs will not run."
# --- 7. The first backup, before day one ends --------------------------------
STAMP="$(date +%Y%m%d-%H%M%S)"
docker compose exec -u www-data -T app php /var/www/html/occ maintenance:mode --on >/dev/null
docker compose exec -T db sh -c 'exec mariadb-dump --single-transaction -u"$MARIADB_USER" -p"$MARIADB_PASSWORD" "$MARIADB_DATABASE"' \
| gzip > "$APP_DIR/backups/nextcloud-db-${STAMP}.sql.gz"
sudo tar -C "$APP_DIR/html" -czf "$APP_DIR/backups/nextcloud-files-${STAMP}.tar.gz" config data
sudo tar -czf "$APP_DIR/backups/nextcloud-config-${STAMP}.tar.gz" -C "$APP_DIR" compose.yml .env -C /etc/caddy Caddyfile
docker compose exec -u www-data -T app php /var/www/html/occ maintenance:mode --off >/dev/null
ls -lh "$APP_DIR/backups/"
[ -s "$APP_DIR/backups/nextcloud-db-${STAMP}.sql.gz" ] || die "the database dump is empty"
[ -s "$APP_DIR/backups/nextcloud-files-${STAMP}.tar.gz" ] || die "the files archive is empty"
cat <<-DONE
Nextcloud is answering at https://${DOMAIN_HOST}
1. Sign in as admin. Your password is in $APP_DIR/.env, mode 600:
sudo grep NEXTCLOUD_ADMIN_PASSWORD $APP_DIR/.env
Put it in your password manager. It was not printed here, and the
account name cannot be changed later.
2. The setup wizard is already closed: this script asserted that
https://${DOMAIN_HOST}/ serves the login page and that status.php
reports installed:true.
3. Four containers are running: the site, the scheduler that runs
cron.php every five minutes, MariaDB and Redis. Redis is holding the
file locks, which this script checked rather than assumed.
4. First backup written to $APP_DIR/backups: a database dump, a files
archive and a config archive. They are on the same disk as the data,
which is not a backup. Copy them somewhere else tonight:
scp vps:$APP_DIR/backups/* ~/backups/nextcloud/
DONEWhat you're signing up for
The part a vendor's comparison page leaves out. None of it is a reason not to do this; all of it is yours the moment you cancel Dropbox.
- You own the backups, and here that means two artifacts taken at one moment: a MariaDB dump and the file tree. Restore one without the other and Nextcloud shows an index full of files the disk does not have.
- Four containers, and one of them exists only to run a scheduler every five minutes. When background jobs stop, nothing errors: previews stop appearing, shares stop expiring, and you find out weeks later from a line in the admin overview.
- It is a platform with an app store, and the temptation is to switch everything on. Every app you enable is code you now run, memory you now spend, and one more thing that can block the next major upgrade.
- Major versions land about twice a year and the container refuses to skip one, so an instance left alone for two years is a staircase of upgrades rather than a single pull.
- What Dropbox was selling that you now do yourself: the uptime, the bandwidth bill when someone downloads a 4 GB share, and the promise that a link you sent last year still opens for a stranger on the other side of the world.
Where this came from
“Nextcloud needs a minimum of 128MB RAM per process, and we recommend a minimum of 512MB RAM per process.”
- The image runs the install itself when NEXTCLOUD_ADMIN_USER and NEXTCLOUD_ADMIN_PASSWORD are set before the first launch, and that same branch is the only place NEXTCLOUD_TRUSTED_DOMAINS is ever applied. source
- Upstream's own compose example runs the scheduler as a second copy of the same image with its entrypoint set to /cron.sh, and notes that its volumes must match the app container's. source
- Nextcloud 34 lists MariaDB 11.8 as its recommended database version and asks for a minimum of 512 MB of RAM per PHP process. source
- Behind a proxy that terminates TLS, overwriteprotocol sets the scheme Nextcloud builds its URLs with, and trusted_proxies accepts IPv4 ranges in CIDR notation. source
- Nextcloud always treats localhost as a trusted domain whatever the trusted_domains list contains, which is why the local path needs no hostname registered anywhere. source
Questions people actually ask
Answered from this page's own data — the same numbers, in sentences.
Can I self-host Dropbox?
Not Dropbox itself — the vendor does not ship a version you can run on your own server. What you can self-host is the job people pay it for, and the answer to that is Nextcloud. Files, calendars and contacts on hardware you control, with the desktop and mobile clients pointed at it instead of somebody else's cloud. The install is one weekend: 4 containers behind Caddy with automatic TLS, secrets generated on the server rather than in a chat window, and a first backup taken before the agent says it is done, in about 300 minutes. The prompt on this page does it; the compose.yml, Caddyfile and install.sh below do the same install with no agent at all.
What replaces Dropbox?
Nextcloud. Files, calendars and contacts on hardware you control, with the desktop and mobile clients pointed at it instead of somebody else's cloud. The only replacement here that keeps the whole shape of what you were paying for: a folder that syncs both ways through official desktop and mobile clients, plus share links, versions, and calendars and contacts your phone can subscribe to. What you take on is the operating: four containers, a database dump and a file tree that have to be backed up at the same moment, and a major upgrade about twice a year that will not let you skip a version. Nextcloud is AGPL-3.0-licensed and free; nothing on this page is a hosted service we sell you.
What does self-hosting cost compared to Dropbox?
2048 MB of RAM and 10 GB of disk — the smallest tier most VPS hosts sell, about $10 a month. Nextcloud itself is free and AGPL-3.0-licensed; the bill is the server, plus a domain you probably already own. What you stop paying: Dropbox Plus, $11.99/mo — $143.88 a year.
How hard is it really?
ONE WEEKEND — 3–24 hours. The rule that produced that verdict: four containers. Four services still fits in a weekend, but part of that weekend is spent reading logs to work out which of the four is the one that is wrong. The tier is derived from seven countable facts about the Nextcloud install, not from anyone's impression of it, and the whole rubric is published on the methodology page.
Can I run Nextcloud on my own computer instead of a server?
Yes — that is the second path in the prompt box above. "On my computer" installs the same Nextcloud on the machine you are sitting at: no VPS, no domain, no DNS, and nothing exposed to the internet. It checks for Docker first and installs Docker Desktop if the machine does not have it — macOS, Windows and Linux each get their own step — then binds everything to loopback, so the app answers on http://localhost and only on that computer. The catch: Only this computer can reach it, so the phone app and the desktop client sync from here and nowhere else, and only while the machine is awake. Same discipline as the cloud path: pinned images, secrets generated on the machine, and a first backup taken before the prompt says it is done.
Content last checked 2026-08-06. Verdicts are derived from the published rubric on /methodology; corrections go through the issue tracker.