# Nextcloud Self-hosted VPS Docker Compose setup for a self-hosted VPS running Nextcloud, Gitea, and monitoring — managed as a GitOps-style repo. ## Architecture ```mermaid graph TB Internet([Internet]) subgraph VPS["VPS (t-gstone.de)"] subgraph proxy_net["proxy network"] Caddy["Caddy
reverse proxy + auto HTTPS"] end subgraph nc_stack["Nextcloud Stack"] Nginx["Nginx
static files + FastCGI proxy"] NC["Nextcloud FPM
PHP processing"] Cron["Cron
background jobs"] PG["PostgreSQL 17"] Redis["Redis 8"] end subgraph gitea_stack["Gitea Stack"] Gitea["Gitea
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.de` with DNS control - Git installed locally Check your VPS OS: ```bash cat /etc/os-release ``` ### Swap (recommended) If your VPS has no swap, add a 2 GB swapfile to prevent OOM kills: ```bash 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` | `` | | `git.t-gstone.de` | `` | ## 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: ```bash # 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: ```bash # 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 ```bash # 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 ```bash cd ~/nextcloud-selfhosted git pull sudo docker compose --env-file .env up -d ``` ## Quick Start ```bash # 1. Clone this repo on the VPS git clone && 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: ```bash # 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: ```bash 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: ```bash # General syntax sudo docker exec -u www-data nextcloud php occ # 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 1. Create a new directory: `mkdir myapp/` 2. Create `myapp/docker-compose.yml`: - Join the `proxy` external network - Bind mount data to `${DATA_ROOT}/myapp/` - Add `myapp/.env.example` if the service needs secrets 3. Add `- path: myapp/docker-compose.yml` to root `docker-compose.yml` 4. Add a reverse proxy entry in `caddy/Caddyfile`: ``` myapp.t-gstone.de { reverse_proxy myapp:8080 } ``` 5. Reload Caddy: `docker exec caddy caddy reload --config /etc/caddy/Caddyfile` 6. Add a DNS A record for `myapp.t-gstone.de` -> VPS IP 7. Add data directory creation to `scripts/deploy.sh` 8. Add backup steps to `scripts/backup.sh` if the service has persistent data ## Backup & Restore ### Creating Backups ```bash ./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: ```bash crontab -e # Add: 0 3 * * * /path/to/scripts/backup.sh >> /var/log/backup.log 2>&1 ``` ### Restoring ```bash ./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 1. Sign up at [grafana.com](https://grafana.com) (free tier) 2. Go to **My Account** -> **Grafana Cloud** -> your stack 3. Find your Loki and Prometheus endpoints + credentials 4. Fill in `monitoring/.env` with those values 5. 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](https://grafana.com/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) ```bash 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 ```bash apt install fail2ban ``` Create `/etc/fail2ban/jail.local`: ```ini [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`: ```ini [Definition] failregex = ^.*Login failed.*Remote IP.*.*$ ``` ### Unattended Security Upgrades ```bash 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