Add cAdvisor container to the monitoring stack for container-level metrics. Configure Alloy to scrape cAdvisor. Expand the README Recommended Alerts section with exact PromQL/LogQL queries, thresholds, and Grafana alert rule configuration for all five alerts.
Nextcloud Self-hosted VPS
Docker Compose setup for a self-hosted VPS running Nextcloud, Gitea, and monitoring — managed as a GitOps-style repo.
Architecture
graph TB
Internet([Internet])
subgraph VPS["VPS (t-gstone.de)"]
subgraph proxy_net["proxy network"]
Caddy["Caddy<br/>reverse proxy + auto HTTPS"]
end
subgraph nc_stack["Nextcloud Stack"]
Nginx["Nginx<br/>static files + FastCGI proxy"]
NC["Nextcloud FPM<br/>PHP processing"]
Cron["Cron<br/>background jobs"]
PG["PostgreSQL 17"]
Redis["Redis 8"]
end
subgraph gitea_stack["Gitea Stack"]
Gitea["Gitea<br/>rootless, SQLite"]
end
subgraph mon_stack["Monitoring Stack"]
Alloy["Grafana Alloy"]
end
end
GrafanaCloud([Grafana Cloud])
Internet -->|":443 HTTPS"| Caddy
Internet -->|":2222 SSH"| Gitea
Caddy -->|"nextcloud.t-gstone.de"| Nginx
Caddy -->|"git.t-gstone.de"| Gitea
Nginx -->|":9000 FastCGI"| NC
NC --> PG
NC --> Redis
Cron --> PG
Cron --> Redis
Alloy -->|"logs + metrics"| GrafanaCloud
Prerequisites
- A VPS with SSH access (minimum 1 core, 3 GB RAM)
- Domain
t-gstone.dewith DNS control - Git installed locally
Check your VPS OS:
cat /etc/os-release
Swap (recommended)
Check current memory and swap:
free -h
If swap shows 0B, add a 2 GB swapfile to prevent OOM kills:
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
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 --env-file .env -f caddy/docker-compose.yml up -d
docker compose --env-file .env -f gitea/docker-compose.yml up -d
docker compose --env-file .env -f monitoring/docker-compose.yml up -d
docker compose --env-file .env -f nextcloud/docker-compose.yml up -d
docker compose --env-file .env -f gitea/docker-compose.yml logs -f
Running Nextcloud OCC Commands
Nextcloud's occ command-line tool must run as the www-data user inside the container:
# General syntax
sudo docker exec -u www-data nextcloud php occ <command>
# Examples
sudo docker exec -u www-data nextcloud php occ status
sudo docker exec -u www-data nextcloud php occ config:list
sudo docker exec -u www-data nextcloud php occ app:list
sudo docker exec -u www-data nextcloud php occ db:add-missing-indices
sudo docker exec -u www-data nextcloud php occ maintenance:repair --include-expensive
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 -> New alert rule). Choose Grafana-managed rule and select the appropriate data source (Prometheus or Loki).
| Alert | Condition | Severity |
|---|---|---|
| Disk usage high | Available disk < 20% | Critical |
| Container restarting | Restart count > 3 in 10 min | Warning |
| High memory usage | Available memory < 10% | Warning |
| High CPU usage | CPU usage > 90% sustained 5 min | Warning |
| Nextcloud cron stale | No cron log lines in 15 min | Warning |
Disk usage high
Fires when any filesystem drops below 20% free space.
- Data source: Prometheus
- Query (A):
node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} * 100 - Expression (B): Threshold —
A IS BELOW 20 - Evaluate every:
1m - Pending period (For):
5m - Labels:
severity: critical
Container restarting
Fires when any container's start time changes more than 3 times in 10 minutes, indicating repeated restarts. Requires cAdvisor (included in the monitoring stack).
- Data source: Prometheus
- Query (A):
changes(container_start_time_seconds{name!=""}[10m]) - Expression (B): Threshold —
A IS ABOVE 3 - Evaluate every:
1m - Pending period (For):
0s - Labels:
severity: warning
High memory usage
Fires when available memory drops below 10% of total.
- Data source: Prometheus
- Query (A):
node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 100 - Expression (B): Threshold —
A IS BELOW 10 - Evaluate every:
1m - Pending period (For):
5m - Labels:
severity: warning
High CPU usage
Fires when average CPU usage exceeds 90% for 5 minutes.
- Data source: Prometheus
- Query (A):
avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100 - Expression (B): Threshold —
A IS BELOW 10 - Evaluate every:
1m - Pending period (For):
5m - Labels:
severity: warning
Nextcloud cron stale
Fires when no log output from the nextcloud-cron container appears for 15 minutes, indicating background jobs have stopped.
- Data source: Loki
- Query (A):
count_over_time({container="/nextcloud-cron"}[15m]) - Expression (B): Threshold —
A IS BELOW 1 - Alert condition: also trigger on No Data
- Evaluate every:
5m - Pending period (For):
0s - Labels:
severity: 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