feat: Use Hetzner Volumes for Nextcloud data storage (issue #18)

Implement persistent block storage for Nextcloud user data, separating application and data layers:

OpenTofu Changes:
- tofu/volumes.tf: Create and attach Hetzner Volumes per client
  - Configurable size per client (default 100 GB for dev)
  - ext4 formatted, attached but not auto-mounted
- tofu/variables.tf: Add nextcloud_volume_size to client config
- tofu/terraform.tfvars: Set volume size for dev client (100 GB ~€5.40/mo)

Ansible Changes:
- ansible/roles/nextcloud/tasks/mount-volume.yml: New mount tasks
  - Detect volume device automatically
  - Format if needed, mount at /mnt/nextcloud-data
  - Add to fstab for persistence
  - Set correct permissions for www-data
- ansible/roles/nextcloud/tasks/main.yml: Include volume mounting
- ansible/roles/nextcloud/templates/docker-compose.nextcloud.yml.j2:
  - Use host mount /mnt/nextcloud-data/data instead of Docker volume
  - Keep app code in Docker volume (nextcloud-app)
  - User data now on Hetzner Volume

Scripts:
- scripts/resize-client-volume.sh: Online volume resizing
  - Resize via Hetzner API
  - Expand filesystem automatically
  - Show cost impact
  - Verify new size

Documentation:
- docs/storage-architecture.md: Complete storage guide
  - Architecture diagrams
  - Volume specifications
  - Sizing guidelines
  - Operations procedures
  - Performance considerations
  - Troubleshooting guide

- docs/volume-migration.md: Step-by-step migration
  - Safe migration from Docker volumes
  - Rollback procedures
  - Verification checklist
  - Timeline estimates

Benefits:
 Data independent from server instance
 Resize storage without rebuilding server
 Easy data migration between servers
 Better separation of concerns (app vs data)
 Simplified backup strategy
 Cost-optimized (pay for what you use)

Volume Pricing:
- 50 GB: ~€2.70/month
- 100 GB: ~€5.40/month
- 250 GB: ~€13.50/month
- Resizable online, no downtime

Note: Existing clients require manual migration
Follow docs/volume-migration.md for safe migration procedure

Closes #18

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Pieter 2026-01-17 21:07:48 +01:00
parent 0c4d536246
commit 9eb6f2028a
8 changed files with 1162 additions and 8 deletions

View file

@ -1,6 +1,12 @@
--- ---
# Main tasks for Nextcloud deployment # Main tasks for Nextcloud deployment
- name: Include volume mounting tasks
include_tasks: mount-volume.yml
tags:
- nextcloud
- volume
- name: Include Docker deployment tasks - name: Include Docker deployment tasks
include_tasks: docker.yml include_tasks: docker.yml
tags: tags:

View file

@ -0,0 +1,74 @@
---
# Mount Hetzner Volume for Nextcloud Data Storage
#
# This task file handles mounting the Hetzner Volume that stores Nextcloud user data.
# The volume is created and attached by OpenTofu, we just mount it here.
- name: Wait for volume device to appear
wait_for:
path: /dev/disk/by-id/
timeout: 30
register: disk_ready
- name: Find Nextcloud volume device
shell: |
ls -1 /dev/disk/by-id/scsi-0HC_Volume_* 2>/dev/null | grep -i "nextcloud-data-{{ client_name }}" | head -1
register: volume_device_result
changed_when: false
failed_when: volume_device_result.rc != 0
- name: Set volume device fact
set_fact:
volume_device: "{{ volume_device_result.stdout }}"
- name: Display found volume device
debug:
msg: "Found Nextcloud volume at: {{ volume_device }}"
- name: Check if volume is already formatted
shell: |
blkid {{ volume_device }} | grep -q 'TYPE="ext4"'
register: volume_formatted
changed_when: false
failed_when: false
- name: Format volume as ext4 if not formatted
filesystem:
fstype: ext4
dev: "{{ volume_device }}"
when: volume_formatted.rc != 0
- name: Create mount point directory
file:
path: /mnt/nextcloud-data
state: directory
mode: '0755'
- name: Mount Nextcloud data volume
mount:
path: /mnt/nextcloud-data
src: "{{ volume_device }}"
fstype: ext4
state: mounted
opts: defaults,discard
register: mount_result
- name: Ensure mount persists across reboots
mount:
path: /mnt/nextcloud-data
src: "{{ volume_device }}"
fstype: ext4
state: present
opts: defaults,discard
- name: Create Nextcloud data directory on volume
file:
path: /mnt/nextcloud-data/data
state: directory
owner: www-data
group: www-data
mode: '0750'
- name: Display mount success
debug:
msg: "Nextcloud volume successfully mounted at /mnt/nextcloud-data"

View file

@ -35,7 +35,8 @@ services:
- nextcloud-db - nextcloud-db
- nextcloud-redis - nextcloud-redis
volumes: volumes:
- nextcloud-data:/var/www/html - nextcloud-app:/var/www/html
- /mnt/nextcloud-data/data:/var/www/html/data # User data on Hetzner Volume
entrypoint: /cron.sh entrypoint: /cron.sh
networks: networks:
- nextcloud-internal - nextcloud-internal
@ -49,7 +50,8 @@ services:
- nextcloud-db - nextcloud-db
- nextcloud-redis - nextcloud-redis
volumes: volumes:
- nextcloud-data:/var/www/html - nextcloud-app:/var/www/html
- /mnt/nextcloud-data/data:/var/www/html/data # User data on Hetzner Volume
environment: environment:
# Database configuration # Database configuration
POSTGRES_HOST: {{ nextcloud_db_host }} POSTGRES_HOST: {{ nextcloud_db_host }}
@ -158,5 +160,6 @@ volumes:
name: nextcloud-db-data name: nextcloud-db-data
nextcloud-redis-data: nextcloud-redis-data:
name: nextcloud-redis-data name: nextcloud-redis-data
nextcloud-data: nextcloud-app:
name: nextcloud-data name: nextcloud-app
# Note: nextcloud-data volume removed - user data now stored on Hetzner Volume at /mnt/nextcloud-data

View file

@ -0,0 +1,449 @@
# Storage Architecture
Comprehensive guide to storage architecture using Hetzner Volumes for Nextcloud data.
## Overview
The infrastructure uses **Hetzner Volumes** (block storage) for Nextcloud user data, separating application and data layers:
- **Server local disk**: Operating system, Docker images, application code
- **Hetzner Volume**: Nextcloud user files (/var/www/html/data)
- **Docker volumes**: Database and Redis data (ephemeral, can be rebuilt)
## Architecture Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ Hetzner Cloud Server (cpx22) │
│ │
│ ┌──────────────────────┐ ┌────────────────────────┐ │
│ │ Local Disk (80 GB) │ │ Hetzner Volume (100GB) │ │
│ │ │ │ │ │
│ │ - OS (Ubuntu 24.04) │ │ Mounted at: │ │
│ │ - Docker images │ │ /mnt/nextcloud-data │ │
│ │ - Application code │ │ │ │
│ │ - Config files │ │ Contains: │ │
│ │ │ │ - Nextcloud user files │ │
│ │ Docker volumes: │ │ - Uploaded documents │ │
│ │ - postgres-db │ │ - Photos, videos │ │
│ │ - redis-cache │ │ - All user data │ │
│ │ - nextcloud-app │ │ │ │
│ └──────────────────────┘ └────────────────────────┘ │
│ │ │ │
│ └────────────────────────────────┘ │
│ Both accessible to │
│ Docker containers │
└─────────────────────────────────────────────────────────────┘
```
## Benefits
### 1. Data Independence
- User data survives server rebuilds
- Can detach volume from one server and attach to another
- Easier disaster recovery
### 2. Flexible Scaling
- Resize storage without touching server
- Pay only for storage you need
- Start small (100 GB), grow as needed
### 3. Better Separation
- Application layer (ephemeral, can be rebuilt)
- Data layer (persistent, backed up)
- Clear distinction between code and content
### 4. Simplified Backups
- Snapshot volumes independently
- Smaller, faster snapshots (only data, not OS)
- Point-in-time recovery of user files
### 5. Cost Optimization
- Small clients: 50 GB (~€2.70/month)
- Medium clients: 100 GB (~€5.40/month)
- Large clients: 250+ GB (~€13.50+/month)
- Only pay for what you use
## Volume Specifications
| Feature | Value |
|---------|-------|
| Minimum size | 10 GB |
| Maximum size | 10 TB (10,000 GB) |
| Pricing | €0.054/GB/month |
| Performance | Fast NVMe SSD |
| IOPS | High performance |
| Filesystem | ext4 (pre-formatted) |
| Snapshots | Supported |
| Backups | Via Hetzner API |
## How It Works
### 1. OpenTofu Creates Volume
When deploying a client:
```hcl
# tofu/volumes.tf
resource "hcloud_volume" "nextcloud_data" {
for_each = var.clients
name = "nextcloud-data-${each.key}"
size = each.value.nextcloud_volume_size # e.g., 100 GB
location = each.value.location
format = "ext4"
}
resource "hcloud_volume_attachment" "nextcloud_data" {
for_each = var.clients
volume_id = hcloud_volume.nextcloud_data[each.key].id
server_id = hcloud_server.client[each.key].id
automount = false
}
```
### 2. Ansible Mounts Volume
During deployment:
```yaml
# ansible/roles/nextcloud/tasks/mount-volume.yml
- Find volume device at /dev/disk/by-id/scsi-0HC_Volume_*
- Format as ext4 (if not already formatted)
- Mount at /mnt/nextcloud-data
- Create data directory with proper permissions
```
### 3. Docker Uses Mount
Docker Compose configuration:
```yaml
services:
nextcloud:
volumes:
- nextcloud-app:/var/www/html # Application code (local)
- /mnt/nextcloud-data/data:/var/www/html/data # User data (volume)
```
## Directory Structure
### On Server Local Disk
```
/var/lib/docker/volumes/
├── nextcloud-app/ # Nextcloud application code
├── nextcloud-db-data/ # PostgreSQL database
└── nextcloud-redis-data/ # Redis cache
/opt/docker/
├── authentik/ # Authentik configuration
├── nextcloud/ # Nextcloud docker-compose.yml
└── traefik/ # Traefik configuration
```
### On Hetzner Volume
```
/mnt/nextcloud-data/
└── data/ # Nextcloud user data directory
├── admin/ # Admin user files
├── user1/ # User 1 files
├── user2/ # User 2 files
└── appdata_*/ # Application data
```
## Volume Sizing Guidelines
### Small Clients (1-10 users)
- **Starting size**: 50 GB
- **Monthly cost**: ~€2.70
- **Use case**: Personal use, small teams
- **Growth**: +10 GB increments
### Medium Clients (10-50 users)
- **Starting size**: 100 GB
- **Monthly cost**: ~€5.40
- **Use case**: Small businesses, departments
- **Growth**: +25 GB increments
### Large Clients (50-200 users)
- **Starting size**: 250 GB
- **Monthly cost**: ~€13.50
- **Use case**: Medium businesses
- **Growth**: +50 GB increments
### Enterprise Clients (200+ users)
- **Starting size**: 500 GB+
- **Monthly cost**: ~€27+
- **Use case**: Large organizations
- **Growth**: +100 GB increments
**Pro tip**: Start conservative and grow as needed. Resizing is online and takes seconds.
## Volume Operations
### Resize Volume
Increase volume size (cannot decrease):
```bash
./scripts/resize-client-volume.sh <client> <new_size_gb>
```
Example:
```bash
# Resize dev client from 100 GB to 200 GB
./scripts/resize-client-volume.sh dev 200
```
The script will:
1. Resize via Hetzner API
2. Expand filesystem
3. Verify new size
4. Show cost increase
**Note**: Resizing is **online** (no downtime) and **instant**.
### Snapshot Volume
Create a point-in-time snapshot:
```bash
# Via Hetzner Cloud Console
# Or via API:
hcloud volume create-snapshot nextcloud-data-dev \
--description "Before major update"
```
### Restore from Snapshot
1. Create new volume from snapshot
2. Attach to server
3. Update mount in Ansible
4. Restart Nextcloud containers
### Detach and Move Volume
Move data between servers:
```bash
# 1. Stop Nextcloud on old server
ansible old-server -i hcloud.yml -m shell -a "docker stop nextcloud"
# 2. Detach volume via Hetzner Console or API
hcloud volume detach nextcloud-data-client1
# 3. Attach to new server
hcloud volume attach nextcloud-data-client1 --server new-server
# 4. Mount on new server
ansible new-server -i hcloud.yml -m shell -a "mount /dev/disk/by-id/scsi-0HC_Volume_* /mnt/nextcloud-data"
# 5. Start Nextcloud
ansible new-server -i hcloud.yml -m shell -a "docker start nextcloud"
```
## Backup Strategy
### Option 1: Hetzner Volume Snapshots
**Pros:**
- Fast (incremental)
- Integrated with Hetzner
- Point-in-time recovery
**Cons:**
- Stored in same region
- Not off-site
**Implementation:**
```bash
# Daily snapshots via cron
0 2 * * * hcloud volume create-snapshot nextcloud-data-prod \
--description "Daily backup $(date +%Y-%m-%d)"
```
### Option 2: Rsync to External Storage
**Pros:**
- Off-site backup
- Full control
- Can use any storage provider
**Cons:**
- Slower
- More complex
**Implementation:**
```bash
# Backup to external server
ansible client -i hcloud.yml -m shell -a "\
rsync -av /mnt/nextcloud-data/data/ \
backup-server:/backups/client/nextcloud/"
```
### Option 3: Nextcloud Built-in Backup
**Pros:**
- Uses Nextcloud's own backup tools
- Consistent with application state
**Cons:**
- Slower than volume snapshots
**Implementation:**
```bash
# Using occ command
docker exec -u www-data nextcloud php occ maintenance:mode --on
rsync -av /mnt/nextcloud-data/ /backup/location/
docker exec -u www-data nextcloud php occ maintenance:mode --off
```
## Performance Considerations
### Hetzner Volume Performance
| Metric | Specification |
|--------|---------------|
| Type | NVMe SSD |
| IOPS | High (exact spec varies) |
| Throughput | Fast sequential R/W |
| Latency | Low (local to server) |
### Optimization Tips
1. **Use ext4 filesystem** (default, well-tested)
2. **Enable discard** for SSD optimization (default in our setup)
3. **Monitor I/O** with `iostat -x 1`
4. **Check volume usage** regularly
### Monitoring
```bash
# Check volume usage
df -h /mnt/nextcloud-data
# Check I/O stats
iostat -x 1 /dev/disk/by-id/scsi-0HC_Volume_*
# Check mount status
mount | grep nextcloud-data
```
## Troubleshooting
### Volume Not Mounting
**Problem:** Volume doesn't mount after server restart
**Solutions:**
1. Check if volume is attached:
```bash
lsblk
ls -la /dev/disk/by-id/scsi-0HC_Volume_*
```
2. Check fstab entry:
```bash
cat /etc/fstab | grep nextcloud-data
```
3. Manually mount:
```bash
mount /dev/disk/by-id/scsi-0HC_Volume_* /mnt/nextcloud-data
```
4. Re-run Ansible:
```bash
ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit client --tags volume
```
### Volume Full
**Problem:** Nextcloud reports "not enough space"
**Solutions:**
1. Check usage:
```bash
df -h /mnt/nextcloud-data
```
2. Resize volume:
```bash
./scripts/resize-client-volume.sh client 200
```
3. Clean up old files:
```bash
docker exec -u www-data nextcloud php occ files:scan --all
docker exec -u www-data nextcloud php occ files:cleanup
```
### Permission Issues
**Problem:** Nextcloud can't write to volume
**Solutions:**
1. Check ownership:
```bash
ls -la /mnt/nextcloud-data/
```
2. Fix permissions:
```bash
chown -R www-data:www-data /mnt/nextcloud-data/data
chmod -R 750 /mnt/nextcloud-data/data
```
3. Re-run mount tasks:
```bash
ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit client --tags volume
```
### Volume Detached Accidentally
**Problem:** Volume was detached and lost mount
**Solutions:**
1. Re-attach via Hetzner Console or API
2. Remount:
```bash
ansible client -i hcloud.yml -m shell -a "\
mount /dev/disk/by-id/scsi-0HC_Volume_* /mnt/nextcloud-data"
```
3. Restart Nextcloud:
```bash
docker restart nextcloud nextcloud-cron
```
## Cost Analysis
### Example Scenarios
**Scenario 1: 10 Clients, 100 GB each**
- Volume cost: 10 × 100 GB × €0.054 = €54/month
- Server cost: 10 × €7/month = €70/month
- **Total**: €124/month
**Scenario 2: 5 Small + 3 Medium + 2 Large**
- Small (50 GB): 5 × €2.70 = €13.50
- Medium (100 GB): 3 × €5.40 = €16.20
- Large (250 GB): 2 × €13.50 = €27.00
- **Volume total**: €56.70/month
- Plus server costs
**Cost Savings vs Local Disk:**
- Can use smaller servers (cheaper compute)
- Pay only for storage needed
- Resize incrementally vs over-provisioning
## Migration from Local Volumes
See [volume-migration.md](volume-migration.md) for detailed migration procedures.
## Related Documentation
- [Volume Migration Guide](volume-migration.md) - Migrating existing clients
- [Deployment Guide](deployment.md) - Full deployment with volumes
- [Maintenance Tracking](maintenance-tracking.md) - Monitoring and updates

398
docs/volume-migration.md Normal file
View file

@ -0,0 +1,398 @@
# Volume Migration Guide
Step-by-step guide for migrating existing Nextcloud clients from local Docker volumes to Hetzner Volumes.
## Overview
This guide covers migrating an existing client (like `dev`) that currently stores Nextcloud data in a Docker volume to the new Hetzner Volume architecture.
**Migration is SAFE and REVERSIBLE** - we keep the old data until verification is complete.
## Prerequisites
- Client currently deployed and running
- SSH access to the server
- Hetzner API token (`HCLOUD_TOKEN`)
- SOPS age key for secrets (`SOPS_AGE_KEY_FILE`)
- At least 30 minutes of maintenance window
## Migration Steps
### Phase 1: Preparation
#### 1. Verify Current State
```bash
# Check client is running
./scripts/client-status.sh dev
# Check current data location
cd ansible
ansible dev -i hcloud.yml -m shell -a "docker inspect nextcloud | jq '.[0].Mounts'"
```
Expected output shows Docker volume:
```json
{
"Type": "volume",
"Name": "nextcloud-data",
"Source": "/var/lib/docker/volumes/nextcloud-data/_data",
"Destination": "/var/www/html"
}
```
#### 2. Check Data Size
```bash
# Check how much data we're migrating
ansible dev -i hcloud.yml -m shell -a "\
du -sh /var/lib/docker/volumes/nextcloud-data/_data/data"
```
Note the size - you'll need a volume at least this big (we recommend 2x for growth).
#### 3. Notify Users
⚠️ **Important**: Inform users that Nextcloud will be unavailable during migration (typically 10-30 minutes depending on data size).
### Phase 2: Create and Attach Volume
#### 4. Update OpenTofu Configuration
Already done if you're following the issue #18 implementation:
```hcl
# tofu/terraform.tfvars
clients = {
dev = {
# ... existing config ...
nextcloud_volume_size = 100 # Adjust based on current data size
}
}
```
#### 5. Apply OpenTofu Changes
```bash
cd tofu
# Review changes
tofu plan
# Apply - this creates the volume and attaches it
tofu apply
```
Expected output:
```
+ hcloud_volume.nextcloud_data["dev"]
+ hcloud_volume_attachment.nextcloud_data["dev"]
```
The volume is now attached to the server but not yet mounted.
### Phase 3: Stop Services and Mount Volume
#### 6. Enable Maintenance Mode
```bash
cd ansible
# Enable Nextcloud maintenance mode
ansible dev -i hcloud.yml -m shell -a "\
docker exec -u www-data nextcloud php occ maintenance:mode --on"
```
#### 7. Stop Nextcloud Containers
```bash
# Stop Nextcloud and cron (keep database and redis running)
ansible dev -i hcloud.yml -m shell -a "\
docker stop nextcloud nextcloud-cron"
```
#### 8. Mount the Volume
```bash
# Run Ansible volume mounting tasks
ansible-playbook -i hcloud.yml playbooks/deploy.yml \
--limit dev \
--tags volume
```
This will:
- Find the volume device
- Format as ext4 (if needed)
- Mount at `/mnt/nextcloud-data`
- Create data directory with correct permissions
- Add to `/etc/fstab` for persistence
#### 9. Verify Mount
```bash
# Check mount is successful
ansible dev -i hcloud.yml -m shell -a "df -h /mnt/nextcloud-data"
ansible dev -i hcloud.yml -m shell -a "ls -la /mnt/nextcloud-data"
```
### Phase 4: Migrate Data
#### 10. Copy Data to Volume
```bash
# Copy all data from Docker volume to Hetzner Volume
ansible dev -i hcloud.yml -m shell -a "\
rsync -avh --progress \
/var/lib/docker/volumes/nextcloud-data/_data/data/ \
/mnt/nextcloud-data/data/" -b
```
This will take some time depending on data size. Progress is shown.
**Estimated times:**
- 1 GB: ~30 seconds
- 10 GB: ~5 minutes
- 50 GB: ~20 minutes
- 100 GB: ~40 minutes
#### 11. Verify Data Copy
```bash
# Check data was copied
ansible dev -i hcloud.yml -m shell -a "\
du -sh /mnt/nextcloud-data/data"
# Verify file count matches
ansible dev -i hcloud.yml -m shell -a "\
find /var/lib/docker/volumes/nextcloud-data/_data/data -type f | wc -l && \
find /mnt/nextcloud-data/data -type f | wc -l"
```
Both counts should match.
#### 12. Fix Permissions
```bash
# Ensure correct ownership
ansible dev -i hcloud.yml -m shell -a "\
chown -R www-data:www-data /mnt/nextcloud-data/data" -b
```
### Phase 5: Update Configuration and Restart
#### 13. Update Docker Compose
Already done if you're following the issue #18 implementation. The new template uses:
```yaml
volumes:
- /mnt/nextcloud-data/data:/var/www/html/data
```
#### 14. Deploy Updated Configuration
```bash
# Deploy updated docker-compose.yml
ansible-playbook -i hcloud.yml playbooks/deploy.yml \
--limit dev \
--tags nextcloud,docker
```
This will:
- Update docker-compose.yml
- Restart Nextcloud with new volume mounts
#### 15. Disable Maintenance Mode
```bash
# Turn off maintenance mode
ansible dev -i hcloud.yml -m shell -a "\
docker exec -u www-data nextcloud php occ maintenance:mode --off"
```
### Phase 6: Verification
#### 16. Test Nextcloud Access
```bash
# Check containers are running
ansible dev -i hcloud.yml -m shell -a "docker ps | grep nextcloud"
# Test HTTPS endpoint
curl -I https://nextcloud.dev.vrije.cloud
```
Expected: HTTP 200 OK
#### 17. Login and Verify Files
1. Open https://nextcloud.dev.vrije.cloud in browser
2. Login with admin credentials
3. Navigate to Files
4. Check that all files are visible
5. Try uploading a new file
6. Try downloading an existing file
#### 18. Run Files Scan
```bash
# Scan all files to update Nextcloud's database
ansible dev -i hcloud.yml -m shell -a "\
docker exec -u www-data nextcloud php occ files:scan --all"
```
#### 19. Check for Errors
```bash
# Check Nextcloud logs
ansible dev -i hcloud.yml -m shell -a "\
docker logs nextcloud --tail 50"
# Check for any errors in admin panel
# Login → Settings → Administration → Logging
```
### Phase 7: Cleanup (Optional)
⚠️ **Wait at least 24-48 hours before cleanup to ensure everything works!**
#### 20. Remove Old Docker Volume
After confirming everything works:
```bash
# Remove old Docker volume (THIS IS IRREVERSIBLE!)
ansible dev -i hcloud.yml -m shell -a "\
docker volume rm nextcloud-data"
```
You'll get an error if any container is still using it (good safety check).
## Rollback Procedure
If something goes wrong, you can rollback:
### Quick Rollback (During Migration)
If you haven't removed the old Docker volume:
```bash
# 1. Stop containers
ansible dev -i hcloud.yml -m shell -a "docker stop nextcloud nextcloud-cron"
# 2. Revert docker-compose.yml to use old volume
# (restore from git or manually edit)
# 3. Restart containers
ansible dev -i hcloud.yml -m shell -a "cd /opt/docker/nextcloud && docker-compose up -d"
# 4. Disable maintenance mode
ansible dev -i hcloud.yml -m shell -a "\
docker exec -u www-data nextcloud php occ maintenance:mode --off"
```
### Full Rollback (After Cleanup)
If you've removed the old volume but have a backup:
```bash
# 1. Restore from backup to new volume
# 2. Continue with Phase 5 (restart with new config)
```
## Verification Checklist
After migration, verify:
- [ ] Nextcloud web interface loads
- [ ] Can login with existing credentials
- [ ] All files and folders visible
- [ ] Can upload new files
- [ ] Can download existing files
- [ ] Can edit files (if Collabora Online installed)
- [ ] Sharing links still work
- [ ] Mobile apps can sync
- [ ] Desktop clients can sync
- [ ] No errors in Nextcloud logs
- [ ] No errors in admin panel
- [ ] Volume is mounted in `/etc/fstab`
- [ ] Volume mounts after server reboot
## Common Issues
### Issue: "Permission denied" errors
**Cause:** Wrong ownership on volume
**Fix:**
```bash
ansible dev -i hcloud.yml -m shell -a "\
chown -R www-data:www-data /mnt/nextcloud-data/data" -b
```
### Issue: "Volume not found" in Docker
**Cause:** Docker compose still referencing old volume name
**Fix:**
```bash
# Check docker-compose.yml has correct mount
ansible dev -i hcloud.yml -m shell -a "cat /opt/docker/nextcloud/docker-compose.yml | grep mnt"
# Should show: /mnt/nextcloud-data/data:/var/www/html/data
```
### Issue: Files missing after migration
**Cause:** Incomplete rsync
**Fix:**
```bash
# Re-run rsync (it will only copy missing files)
ansible dev -i hcloud.yml -m shell -a "\
rsync -avh \
/var/lib/docker/volumes/nextcloud-data/_data/data/ \
/mnt/nextcloud-data/data/" -b
```
### Issue: Volume unmounted after reboot
**Cause:** Not in `/etc/fstab`
**Fix:**
```bash
# Re-run volume mounting tasks
ansible-playbook -i hcloud.yml playbooks/deploy.yml --limit dev --tags volume
```
## Post-Migration Benefits
After successful migration:
- ✅ Can resize storage independently: `./scripts/resize-client-volume.sh dev 200`
- ✅ Can snapshot data separately from system
- ✅ Can move data to new server if needed
- ✅ Better separation of application and data
- ✅ Clearer backup strategy
## Timeline Example
Real-world timeline for 10 GB Nextcloud instance:
| Step | Duration | Notes |
|------|----------|-------|
| Preparation | 5 min | Check status, plan |
| Create volume (OpenTofu) | 2 min | Automated |
| Stop services | 1 min | Quick |
| Mount volume | 2 min | Ansible tasks |
| Copy data (10 GB) | 5 min | Depends on size |
| Update config | 2 min | Ansible deploy |
| Restart services | 2 min | Docker restart |
| Verification | 10 min | Manual testing |
| **Total** | **~30 min** | Includes safety checks |
## Related Documentation
- [Storage Architecture](storage-architecture.md) - Understanding volumes
- [Deployment Guide](deployment.md) - New deployments with volumes
- [Client Registry](client-registry.md) - Track migration status

192
scripts/resize-client-volume.sh Executable file
View file

@ -0,0 +1,192 @@
#!/usr/bin/env bash
#
# Resize a client's Nextcloud data volume
#
# Usage: ./scripts/resize-client-volume.sh <client_name> <new_size_gb>
#
# This script will:
# 1. Resize the Hetzner Volume via API
# 2. Expand the filesystem on the server
# 3. Verify the new size
#
# Note: Volumes can only be increased in size, never decreased
set -euo pipefail
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
# Check arguments
if [ $# -ne 2 ]; then
echo -e "${RED}Error: Client name and new size required${NC}"
echo "Usage: $0 <client_name> <new_size_gb>"
echo ""
echo "Example: $0 dev 200"
echo ""
echo "Note: You can only INCREASE volume size, never decrease"
exit 1
fi
CLIENT_NAME="$1"
NEW_SIZE="$2"
# Validate new size is a number
if ! [[ "$NEW_SIZE" =~ ^[0-9]+$ ]]; then
echo -e "${RED}Error: Size must be a number${NC}"
exit 1
fi
# Check minimum size
if [ "$NEW_SIZE" -lt 10 ]; then
echo -e "${RED}Error: Minimum volume size is 10 GB${NC}"
exit 1
fi
# Check maximum size
if [ "$NEW_SIZE" -gt 10000 ]; then
echo -e "${RED}Error: Maximum volume size is 10,000 GB (10 TB)${NC}"
exit 1
fi
# Check required environment variables
if [ -z "${HCLOUD_TOKEN:-}" ]; then
echo -e "${RED}Error: HCLOUD_TOKEN environment variable not set${NC}"
echo "Export your Hetzner Cloud API token:"
echo " export HCLOUD_TOKEN='your-token-here'"
exit 1
fi
echo -e "${BLUE}========================================${NC}"
echo -e "${BLUE}Resizing Nextcloud Volume${NC}"
echo -e "${BLUE}========================================${NC}"
echo ""
echo "Client: $CLIENT_NAME"
echo "New size: ${NEW_SIZE} GB"
echo ""
# Step 1: Get volume ID from Hetzner API
echo -e "${YELLOW}[1/4] Looking up volume...${NC}"
VOLUME_NAME="nextcloud-data-${CLIENT_NAME}"
# Get volume info
VOLUME_INFO=$(curl -s -H "Authorization: Bearer $HCLOUD_TOKEN" \
"https://api.hetzner.cloud/v1/volumes?name=$VOLUME_NAME")
VOLUME_ID=$(echo "$VOLUME_INFO" | jq -r '.volumes[0].id // empty')
CURRENT_SIZE=$(echo "$VOLUME_INFO" | jq -r '.volumes[0].size // empty')
if [ -z "$VOLUME_ID" ] || [ "$VOLUME_ID" = "null" ]; then
echo -e "${RED}Error: Volume '$VOLUME_NAME' not found${NC}"
echo "Make sure the client exists and has been deployed with volume support"
exit 1
fi
echo "Volume ID: $VOLUME_ID"
echo "Current size: ${CURRENT_SIZE} GB"
echo ""
# Check if new size is larger
if [ "$NEW_SIZE" -le "$CURRENT_SIZE" ]; then
echo -e "${RED}Error: New size ($NEW_SIZE GB) must be larger than current size ($CURRENT_SIZE GB)${NC}"
echo "Volumes can only be increased in size, never decreased"
exit 1
fi
# Calculate cost increase
COST_INCREASE=$(echo "scale=2; ($NEW_SIZE - $CURRENT_SIZE) * 0.054" | bc)
echo -e "${YELLOW}Warning: This will increase monthly costs by approximately €${COST_INCREASE}${NC}"
echo ""
read -p "Continue with resize? (yes/no): " confirm
if [ "$confirm" != "yes" ]; then
echo "Resize cancelled"
exit 0
fi
echo ""
# Step 2: Resize volume via API
echo -e "${YELLOW}[2/4] Resizing volume via Hetzner API...${NC}"
RESIZE_RESULT=$(curl -s -X POST \
-H "Authorization: Bearer $HCLOUD_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"size\": $NEW_SIZE}" \
"https://api.hetzner.cloud/v1/volumes/$VOLUME_ID/actions/resize")
ACTION_ID=$(echo "$RESIZE_RESULT" | jq -r '.action.id // empty')
if [ -z "$ACTION_ID" ] || [ "$ACTION_ID" = "null" ]; then
echo -e "${RED}Error: Failed to resize volume${NC}"
echo "$RESIZE_RESULT" | jq .
exit 1
fi
# Wait for resize action to complete
echo "Waiting for resize action to complete..."
while true; do
ACTION_STATUS=$(curl -s -H "Authorization: Bearer $HCLOUD_TOKEN" \
"https://api.hetzner.cloud/v1/volumes/actions/$ACTION_ID" | jq -r '.action.status')
if [ "$ACTION_STATUS" = "success" ]; then
break
elif [ "$ACTION_STATUS" = "error" ]; then
echo -e "${RED}Error: Resize action failed${NC}"
exit 1
fi
sleep 2
done
echo -e "${GREEN}✓ Volume resized${NC}"
echo ""
# Step 3: Expand filesystem on the server
echo -e "${YELLOW}[3/4] Expanding filesystem on server...${NC}"
cd "$PROJECT_ROOT/ansible"
# Find the device
DEVICE_CMD="ls -1 /dev/disk/by-id/scsi-0HC_Volume_* 2>/dev/null | grep -i 'nextcloud-data-${CLIENT_NAME}' | head -1"
DEVICE=$(~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m shell -a "$DEVICE_CMD" -o 2>/dev/null | tail -1 | awk '{print $NF}')
if [ -z "$DEVICE" ]; then
echo -e "${RED}Error: Could not find volume device on server${NC}"
exit 1
fi
echo "Device: $DEVICE"
# Resize filesystem
~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m shell -a "resize2fs $DEVICE" -b
echo -e "${GREEN}✓ Filesystem expanded${NC}"
echo ""
# Step 4: Verify new size
echo -e "${YELLOW}[4/4] Verifying new size...${NC}"
DF_OUTPUT=$(~/.local/bin/ansible -i hcloud.yml "$CLIENT_NAME" -m shell -a "df -h /mnt/nextcloud-data" -o 2>/dev/null | tail -1)
echo "$DF_OUTPUT"
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}✓ Resize complete!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo "Volume resized from ${CURRENT_SIZE} GB to ${NEW_SIZE} GB"
echo "Additional monthly cost: €${COST_INCREASE}"
echo ""
echo "The new storage is immediately available to Nextcloud."
echo ""

View file

@ -30,6 +30,7 @@ variable "clients" {
location = string # e.g., "fsn1" (Falkenstein), "nbg1" (Nuremberg), "hel1" (Helsinki) location = string # e.g., "fsn1" (Falkenstein), "nbg1" (Nuremberg), "hel1" (Helsinki)
subdomain = string # e.g., "alpha" for alpha.platform.nl subdomain = string # e.g., "alpha" for alpha.platform.nl
apps = list(string) # e.g., ["zitadel", "nextcloud"] apps = list(string) # e.g., ["zitadel", "nextcloud"]
nextcloud_volume_size = number # Size in GB for Nextcloud data volume (min 10, max 10000)
})) }))
default = {} default = {}
} }

31
tofu/volumes.tf Normal file
View file

@ -0,0 +1,31 @@
# Hetzner Volumes for Nextcloud Data Storage
#
# Each client gets a dedicated volume for Nextcloud user data.
# Volumes are independent from server instances, enabling:
# - Independent storage scaling
# - Easy data migration between servers
# - Simpler backup/restore procedures
# - Better separation of application and data
resource "hcloud_volume" "nextcloud_data" {
for_each = var.clients
name = "nextcloud-data-${each.key}"
size = each.value.nextcloud_volume_size
location = each.value.location
format = "ext4"
labels = {
client = each.key
purpose = "nextcloud-data"
managed = "terraform"
}
}
resource "hcloud_volume_attachment" "nextcloud_data" {
for_each = var.clients
volume_id = hcloud_volume.nextcloud_data[each.key].id
server_id = hcloud_server.client[each.key].id
automount = false # We mount manually via Ansible for better control
}