Nextcloud Self-hosted VPS
Docker Compose setup for a self-hosted VPS running Nextcloud, Gitea, and monitoring — managed as a GitOps-style repo.
Prerequisites
- A VPS with SSH access
- Domain
t-gstone.dewith DNS control - Git installed locally
Check your VPS OS:
cat /etc/os-release
DNS Setup
Create these A records pointing to your VPS IP:
| Record | Value |
|---|---|
nextcloud.t-gstone.de |
<VPS_IP> |
git.t-gstone.de |
<VPS_IP> |
Local Setup
SSH Access
Add this to ~/.ssh/config on your local machine:
Host t-gstone.de
HostName t-gstone.de
User gstone
Port 55
IdentityFile ~/.ssh/id_ed25519
UseKeychain yes
AddKeysToAgent yes
Generate a key and copy it to the VPS:
# Generate key (skip if you already have ~/.ssh/id_ed25519)
ssh-keygen -t ed25519
# Copy it to the VPS (will ask for your password once)
ssh-copy-id -p 55 gstone@t-gstone.de
# Store passphrase in macOS Keychain
ssh-add --apple-use-keychain ~/.ssh/id_ed25519
After this, ssh t-gstone.de connects without any password prompts.
Syncing to the VPS
If the VPS doesn't have a git remote set up yet, use rsync over SSH to push the repo:
# Sync the repo to the VPS (dry-run first to check what would be sent)
rsync -avz --dry-run -e 'ssh -p 55' \
--exclude '.env' --exclude '*/.env' --exclude '.git' \
./ gstone@t-gstone.de:~/nextcloud-selfhosted/
# Run for real (remove --dry-run)
rsync -avz -e 'ssh -p 55' \
--exclude '.env' --exclude '*/.env' --exclude '.git' \
./ gstone@t-gstone.de:~/nextcloud-selfhosted/
The .env files are excluded because they contain secrets and should be created directly on the VPS from the .env.example templates.
Syncing via Git (recommended)
Now that Gitea is running, you can clone the repo directly on the VPS instead of using rsync.
First-time setup on the VPS
# 1. Add your SSH key to Gitea (via https://git.t-gstone.de user settings)
# 2. Clone the repo
cd ~
git clone ssh://git@git.t-gstone.de:2222/gstone/nextcloud-selfhosted.git
cd nextcloud-selfhosted
# 3. Create .env files from examples
cp .env.example .env
cp nextcloud/.env.example nextcloud/.env
cp gitea/.env.example gitea/.env
cp monitoring/.env.example monitoring/.env
# 4. Edit each .env file with real values
nano .env
nano nextcloud/.env
nano gitea/.env
nano monitoring/.env
# 5. Deploy
sudo ./scripts/deploy.sh
Updating the VPS after pushing changes
cd ~/nextcloud-selfhosted
git pull
sudo docker compose --env-file .env up -d
Quick Start
# 1. Clone this repo on the VPS
git clone <repo-url> && cd nextcloud-selfhosted
# 2. Create .env files from examples
cp .env.example .env
cp nextcloud/.env.example nextcloud/.env
cp gitea/.env.example gitea/.env
cp monitoring/.env.example monitoring/.env
# 3. Edit each .env file with real values
# - Generate strong passwords for Postgres and Nextcloud admin
# - Add Grafana Cloud credentials to monitoring/.env
# 4. Deploy
./scripts/deploy.sh
Services
| Service | Subdomain | Stack |
|---|---|---|
| Nextcloud | nextcloud.t-gstone.de |
Nextcloud + PostgreSQL 16 + Redis 7 |
| Gitea | git.t-gstone.de |
Gitea (SQLite) |
| Caddy | — | Reverse proxy, auto HTTPS |
| Monitoring | — | Grafana Alloy -> Grafana Cloud |
Data Layout
All persistent data lives under /opt/docker-data/:
/opt/docker-data/
├── caddy/data/ # TLS certificates
├── caddy/config/
├── nextcloud/html/ # Nextcloud application
├── nextcloud/data/ # User files
├── nextcloud/db/ # PostgreSQL data
├── gitea/data/ # Repositories + Gitea DB
└── gitea/config/ # Gitea configuration
Managing Services
A root docker-compose.yml includes all stacks, so you can manage everything with one command:
# Start / restart all services
docker compose --env-file .env up -d
# View logs for all services
docker compose --env-file .env logs -f
# Stop everything
docker compose --env-file .env down
You can still target individual services via their compose file:
docker compose -f nextcloud/docker-compose.yml --env-file .env up -d
docker compose -f gitea/docker-compose.yml --env-file .env logs -f
Adding a New Service
- Create a new directory:
mkdir myapp/ - Create
myapp/docker-compose.yml:- Join the
proxyexternal network - Bind mount data to
${DATA_ROOT}/myapp/ - Add
myapp/.env.exampleif the service needs secrets
- Join the
- Add
- path: myapp/docker-compose.ymlto rootdocker-compose.yml - Add a reverse proxy entry in
caddy/Caddyfile:myapp.t-gstone.de { reverse_proxy myapp:8080 } - Reload Caddy:
docker exec caddy caddy reload --config /etc/caddy/Caddyfile - Add a DNS A record for
myapp.t-gstone.de-> VPS IP - Add data directory creation to
scripts/deploy.sh - Add backup steps to
scripts/backup.shif the service has persistent data
Backup & Restore
Creating Backups
./scripts/backup.sh
This dumps the Nextcloud Postgres database, archives Nextcloud data/config and Gitea data, and stores them in
/opt/backups/ with date-stamped filenames. Backups older than 7 days are automatically removed.
Schedule daily backups:
crontab -e
# Add:
0 3 * * * /path/to/scripts/backup.sh >> /var/log/backup.log 2>&1
Restoring
./scripts/restore.sh 2026-03-22
This stops services, restores data from the specified date's backup files, restores the database, and restarts everything.
Backup Strategy Options
The current setup stores backups locally on the same VPS. For production use, consider an off-site strategy:
| Option | Pros | Cons |
|---|---|---|
Local only (/opt/backups/) |
Simplest, no extra cost | Lost if VPS dies |
| rsync to second VPS or home server | Simple, full control | Need a second machine |
| S3-compatible object storage (Backblaze B2, Hetzner Object Storage, Wasabi) | Cheap, durable, off-site | Monthly cost (~$0.005/GB) |
| Restic or BorgBackup to any remote target | Encrypted, deduplicated, incremental | More setup complexity |
Recommendation for a personal setup: Backblaze B2 or Hetzner Object Storage with Restic. Both offer free egress (B2)
or low cost, and Restic handles encryption + deduplication automatically. A cron job running restic backup after
backup.sh completes the pipeline.
Monitoring
Setup
- Sign up at grafana.com (free tier)
- Go to My Account -> Grafana Cloud -> your stack
- Find your Loki and Prometheus endpoints + credentials
- Fill in
monitoring/.envwith those values - Start the monitoring stack:
docker compose --env-file .env up -d
Recommended Alerts
Set these up in Grafana Cloud UI (Alerting -> Alert rules):
| Alert | Condition | Severity |
|---|---|---|
| Disk usage high | node_filesystem_avail_bytes / node_filesystem_size_bytes < 0.2 |
Critical |
| Container restarting | Container restart count > 3 in 10 min | Warning |
| High memory usage | node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes < 0.1 |
Warning |
| High CPU usage | node_cpu_seconds_total idle < 10% sustained 5 min |
Warning |
| Nextcloud cron stale | No log line from nextcloud-cron in 15 min |
Warning |
Recommended Dashboards
Import these from Grafana Dashboards:
- Node Exporter Full (ID: 1860) — CPU, memory, disk, network
- Docker Container Monitoring (ID: 893) — per-container resource usage
- Loki Docker Logs — search and filter container logs
Security Hardening (Reference)
These steps are not automated — apply them manually based on your needs.
Firewall (UFW)
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp # SSH (change if using non-default port)
ufw allow 80/tcp # HTTP (Caddy redirect)
ufw allow 443/tcp # HTTPS
ufw allow 443/udp # HTTPS (HTTP/3 / QUIC)
ufw allow 2222/tcp # Gitea SSH
ufw enable
SSH Hardening
Edit /etc/ssh/sshd_config:
PasswordAuthentication no
PermitRootLogin prohibit-password
MaxAuthTries 3
Then: systemctl restart sshd
fail2ban
apt install fail2ban
Create /etc/fail2ban/jail.local:
[sshd]
enabled = true
maxretry = 5
bantime = 3600
[nextcloud]
enabled = true
port = 80,443
filter = nextcloud
logpath = /opt/docker-data/nextcloud/data/nextcloud.log
maxretry = 5
bantime = 3600
Create /etc/fail2ban/filter.d/nextcloud.conf:
[Definition]
failregex = ^.*Login failed.*Remote IP.*<HOST>.*$
Unattended Security Upgrades
apt install unattended-upgrades
dpkg-reconfigure -plow unattended-upgrades
Docker Daemon
- Keep Docker updated:
apt update && apt upgrade docker-ce - Don't run containers as root unless necessary (Gitea uses rootless image)
- The Docker socket is mounted read-only into the Alloy container — be aware this still grants significant access