2026-03-22 12:04:19 +01:00
2026-03-22 12:04:10 +01:00
2026-03-22 12:04:19 +01:00
2026-03-22 12:03:54 +01:00
2026-03-22 12:03:20 +01:00

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.de with 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>

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
./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

Each service has its own compose file and can be managed independently:

# Restart just Nextcloud
docker compose -f nextcloud/docker-compose.yml --env-file .env up -d

# View logs for Gitea
docker compose -f gitea/docker-compose.yml --env-file .env logs -f

# Stop everything
for svc in monitoring gitea nextcloud caddy; do
  docker compose -f $svc/docker-compose.yml --env-file .env down
done

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 a reverse proxy entry in caddy/Caddyfile:
    myapp.t-gstone.de {
        reverse_proxy myapp:8080
    }
    
  4. Reload Caddy: docker exec caddy caddy reload --config /etc/caddy/Caddyfile
  5. Add a DNS A record for myapp.t-gstone.de -> VPS IP
  6. Add data directory creation to deploy.sh
  7. Add backup steps to backup.sh if the service has persistent data

Backup & Restore

Creating Backups

./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/backup.sh >> /var/log/backup.log 2>&1

Restoring

./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 (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 -f monitoring/docker-compose.yml --env-file .env up -d

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

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
Description
No description provided
Readme 154 KiB
Languages
Shell 100%