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.de with DNS control
  • Git installed locally

Check your VPS OS:

cat /etc/os-release

If your VPS has no swap, 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.

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

  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

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

  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 --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%