Skip to content

Cloud Migration Plan

This document outlines the migration plan for the Magic e-VERSE platform from the current local server (Intel i7-9700KF, 16 GB RAM) to a cloud-hosted setup using Netcup RS 4000 G12 as the primary server, Cloudflare R2 for product image storage, and Hetzner Storage Box for off-site backups.


ComponentSpec
CPUIntel Core i7-9700KF @ 3.60 GHz (8 cores)
RAM16 GB DDR4 (18 GB in swap — critical)
System Disk228 GB SSD (/dev/sda2) — 77% full
Data Disk932 GB HDD (/dev/sdb1/mnt/data) — 64% full
NVMe1.8 TB (/dev/nvme0n1p1/mnt/nvme) — 52% full
NAS Backup3.5 TB + 2.7 TB (SMB mounts)
Docker Containers53 running
Docker Images697 GB (688 GB reclaimable)
Actual container RAM usage~2.2 GB
Total platform data~190 GB (excl. product images)
Product images~220 GB (/mnt/data/pim_data/)

Netcup RS 4000 G12

  • 12 dedicated cores (AMD EPYC 9645, 2024)
  • 32 GB DDR5 ECC RAM
  • 1 TB NVMe SSD
  • 2.5 Gbit/s network
  • €27.08/mo

Cloudflare R2

  • S3-compatible object storage
  • Zero egress fees (free CDN delivery)
  • ~250 GB for product images
  • ~€3.50/mo

Hetzner Storage Box BX11

  • 1 TB backup storage
  • SFTP / rsync / WebDAV
  • Nightly automated backups
  • €3.20/mo

Total Monthly Cost

  • Server: €27.08
  • Images: ~€3.50
  • Backups: €3.20
  • Total: ~€33.78/mo

Best price/performance ratio across all EU providers. Comparison at time of evaluation (March 2026):

ProviderProductCoresRAMStorageNetworkPrice/mo
NetcupRS 4000 G121232 GB DDR51 TB NVMe2.5 Gbit/s€27.08
HetznerCCX33 (Cloud)8 vCPU32 GB240 GB1 Gbit/s€48.49
HetznerAX42 (Dedicated)864 GB1 TB1 Gbit/s€46.00
ContaboVDS M432 GB240 GB500 Mbit/s€35.84
OVHcloudAdvance-1632 GBNVMe1 Gbit/s€110+
FeatureCloudflare R2Firebase StorageHetzner Object Storage
Free storage10 GB5 GB0
Egress costFree€0.12/GB€1/TB
250 GB cost~€3.50/mo~€8-11/mo€6.49/mo
CDNGlobal edgeGoogle CDNNo CDN
S3 compatibleYesNo (proprietary SDK)Yes
  • 1 TB for €3.20/mo via SFTP/rsync
  • Different provider than server = true off-site backup
  • 10 snapshots included
  • Automated via cron + rsync

ServiceContainer(s)Current RAM
8 Commerce Tenantsbackend + storefront + redis + meilisearch (×8)~1.6 GB
Magic PIMbackend + redis~130 MB
Spranzbackend + storefront + redis + meilisearch~60 MB
Master Magicbackend + redis~40 MB
PostgreSQL (shared)magic_pim_postgres_dev~90 MB
MySQLmysql~10 MB
n8n (×3)magic_n8n, magic_n8n_2, magic_n8n_3~120 MB
Image Servicesrembg-service, preflight-service~15 MB
Sshwiftymagic_terminal_sshwifty~4 MB
Vaultwardenvaultwarden~8 MB
Magic Portalportal (Vite + React)~30 MB
Dashboarddashboard API~20 MB
DevDocsStarlight static site~10 MB
Total~48 containers~2.2 GB
DataSizeBucket
/mnt/data/pim_data/spranz/Product imagesmagic-pim-images
/mnt/data/pim_data/langenberg/Product imagesmagic-pim-images
/mnt/data/pim_data/svg/SVG logosmagic-pim-images
Total~220 GB
ServiceReason
Omada Controller (TP-Link)Needs LAN access for network hardware
Nextcloud + OnlyOfficeOptional — can move later or use cloud alternative
ItemReason
Old Docker images (688 GB)Reclaimable, rebuild fresh on new server
Windows SMB sharesLocal storage, not platform-related
NAS backupsReplaced by Hetzner Storage Box

  • Sign up at dash.cloudflare.com
  • Go to R2 → Create Bucket → Name: magic-pim-images
  • Create R2 API token: R2 → Manage API Tokens → Create Token
  • Note down: Account ID, Access Key ID, Secret Access Key
  • Optional: set up custom domain (e.g., images.magiceverse.online)
  • Order BX11 (1 TB) at hetzner.com/storage/storage-box/bx11
  • After provisioning, note: username (uXXXXXX), host (uXXXXXX.your-storagebox.de)
  • Upload your SSH public key via Hetzner Robot panel

Terminal window
# SSH into new Netcup server (IP from Netcup SCP panel)
ssh root@<netcup-ip>
# Set hostname
hostnamectl set-hostname magic-cloud
# Update system
apt update && apt upgrade -y
apt install -y curl git htop rsync rclone unzip
# Set timezone
timedatectl set-timezone Europe/Amsterdam
Terminal window
# Install Docker Engine + Compose plugin
curl -fsSL https://get.docker.com | sh
apt install -y docker-compose-plugin
# Verify
docker --version
docker compose version
# Enable Docker on boot
systemctl enable docker
Terminal window
ufw default deny incoming
ufw default allow outgoing
ufw allow 22/tcp # SSH
ufw allow 80/tcp # HTTP
ufw allow 443/tcp # HTTPS
ufw allow 8000/tcp # Coolify dashboard
ufw enable
Terminal window
# Match current server structure
mkdir -p /mnt/data/magic_omniverse/magic_commerce
mkdir -p /mnt/data/magic_pim
mkdir -p /mnt/data/vaultwarden
mkdir -p /opt/scripts
mkdir -p /opt/backups/databases

2.5 Configure SSH Key for Old Server Access

Section titled “2.5 Configure SSH Key for Old Server Access”
Terminal window
# Generate SSH key on new server
ssh-keygen -t ed25519 -C "magic-cloud"
# Copy to old server so we can pull data
ssh-copy-id adminmichiel1@<old-server-ip>

Phase 3: Upload Product Images to Cloudflare R2

Section titled “Phase 3: Upload Product Images to Cloudflare R2”
Terminal window
# On the CURRENT (old) server
rclone config
# Enter these settings:
# name> r2
# Storage> s3
# provider> Cloudflare
# env_auth> false
# access_key_id> <your R2 access key>
# secret_access_key> <your R2 secret key>
# region> auto
# endpoint> https://<account-id>.r2.cloudflarestorage.com
# acl> (leave blank)
# Edit advanced config?> n
Terminal window
# Dry run first to verify
rclone sync /mnt/data/pim_data/ r2:magic-pim-images/ --dry-run --progress
# If looks good, do the real sync (run in tmux/screen!)
tmux new -s r2sync
rclone sync /mnt/data/pim_data/ r2:magic-pim-images/ --progress --transfers=8
# Verify file count
rclone size r2:magic-pim-images/
Terminal window
# In Cloudflare Dashboard:
# 1. Go to R2 → magic-pim-images → Settings
# 2. Enable "Public Access" via custom domain
# 3. Add domain: images.magiceverse.online
# 4. Cloudflare handles SSL automatically
# Test access:
curl -I https://images.magiceverse.online/spranz/5325-00.001.jpg

Files to update (in magic_development, then sync to all tenants):

File / AreaCurrentNew
PIM backend image serving/mnt/data/pim_data/{supplier}/{file}https://images.magiceverse.online/{supplier}/{file}
Storefront <Image> componentsRelative paths via backend proxyDirect R2 URL
Spranz Designer SVG paths/mnt/data/pim_data/svg/spranz/https://images.magiceverse.online/svg/spranz/
APLT product thumbnailsLocal file readR2 URL
Docker volume mounts/mnt/data/pim_data:/mnt/data/pim_data:roRemove (no longer needed)

Terminal window
# PostgreSQL — all tenant databases + PIM
docker exec magic_pim_postgres_dev pg_dumpall -U postgres > /tmp/postgres_full_$(date +%Y%m%d).sql
# Check size
ls -lh /tmp/postgres_full_*.sql
# MySQL — portal, workflow data
docker exec mysql mysqldump -uroot -p<your-db-password> --all-databases > /tmp/mysql_full_$(date +%Y%m%d).sql

4.2 Transfer Code (run in tmux — takes a while)

Section titled “4.2 Transfer Code (run in tmux — takes a while)”
Terminal window
tmux new -s migration
# Replace <netcup-ip> with actual IP throughout
# 1. Commerce tenants (the big one)
rsync -avz --progress \
--exclude='node_modules' \
--exclude='.next' \
--exclude='dist' \
/mnt/data/magic_omniverse/ root@<netcup-ip>:/mnt/data/magic_omniverse/
# 2. PIM (code only — images are in R2 now)
rsync -avz --progress \
--exclude='node_modules' \
--exclude='.next' \
--exclude='uploads' \
/mnt/data/magic_pim/ root@<netcup-ip>:/mnt/data/magic_pim/
# 3. Vaultwarden data
rsync -avz --progress /mnt/data/vaultwarden/ root@<netcup-ip>:/mnt/data/vaultwarden/
# 4. rembg + preflight services
rsync -avz --progress /home/adminwayne/rembg-service/ root@<netcup-ip>:/opt/services/rembg-service/
rsync -avz --progress /home/adminwayne/preflight-service/ root@<netcup-ip>:/opt/services/preflight-service/
# 5. Database dumps
scp /tmp/postgres_full_*.sql root@<netcup-ip>:/tmp/
scp /tmp/mysql_full_*.sql root@<netcup-ip>:/tmp/
# Note: n8n dirs (magic_n8n, magic_n8n_2, magic_n8n_3) are included
# in step 1 since they live under /mnt/data/magic_omniverse/

Right before you switch DNS, do a final delta sync to catch any changes:

Terminal window
# Quick delta sync — only transfers changed files
rsync -avz --progress --exclude='node_modules' --exclude='.next' --exclude='dist' \
/mnt/data/magic_omniverse/ root@<netcup-ip>:/mnt/data/magic_omniverse/
# Fresh database dump
docker exec magic_pim_postgres_dev pg_dumpall -U postgres > /tmp/postgres_final_$(date +%Y%m%d_%H%M).sql
docker exec mysql mysqldump -uroot -p<your-db-password> --all-databases > /tmp/mysql_final_$(date +%Y%m%d_%H%M).sql
scp /tmp/postgres_final_*.sql /tmp/mysql_final_*.sql root@<netcup-ip>:/tmp/

All commands below run on the new Netcup server.

Terminal window
# One-line Coolify installer
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash
# This installs:
# - Docker (if not already installed)
# - Traefik (reverse proxy + auto-SSL)
# - Coolify dashboard
# - PostgreSQL (for Coolify's own data)
# Coolify dashboard will be available at:
# http://<netcup-ip>:8000
  1. Open http://<netcup-ip>:8000 in your browser
  2. Create admin account
  3. Go to Settings → Servers → verify the local server is detected
  4. Go to Settings → DNS/SSL → set wildcard domain: *.magiceverse.online
  1. In Coolify: Sources → Add → GitHub App
  2. Follow the OAuth flow to connect your GitHub account
  3. Grant access to the magic-commerce repository (created in Phase 5B)

Phase 5A: Initial Migration (Current Setup via Coolify)

Section titled “Phase 5A: Initial Migration (Current Setup via Coolify)”

For the initial migration, we deploy using docker-compose through Coolify while keeping the current code structure.

5A.1 Set Up Shared Infrastructure in Coolify

Section titled “5A.1 Set Up Shared Infrastructure in Coolify”

In Coolify dashboard, create Project: “Shared Infrastructure”:

PostgreSQL:

  1. Add Service → PostgreSQL 16
  2. Set password: use a strong generated password
  3. Expose port 5432 to the Coolify network
  4. Domain: none (internal only)
  5. Deploy

MySQL:

  1. Add Service → MySQL 8
  2. Set root password
  3. Expose port 3306 to the Coolify network
  4. Deploy
Terminal window
# Find the PostgreSQL container Coolify created
docker ps | grep postgres
# Restore PostgreSQL (use the actual container name)
docker exec -i <coolify-postgres-container> psql -U postgres < /tmp/postgres_final_*.sql
# Verify databases
docker exec <coolify-postgres-container> psql -U postgres -c "\l"
# Restore MySQL
docker exec -i <coolify-mysql-container> mysql -uroot -p<password> < /tmp/mysql_final_*.sql

Expected databases:

  • magic_pim
  • magic_b2b_development, magic_b2b_brinxx, magic_b2b_default
  • magic_b2b_logohorloge, magic_b2b_bovisales, magic_b2b_demo
  • magic_b2b_desluis, magic_b2b_jodasign

For each tenant, create a Coolify Project:

Example: Project “Brinxx”

  1. Add Service → Docker Compose
  2. Point to the transferred code at /mnt/data/magic_omniverse/magic_commerce/magic_brinxx/
  3. Set domains in Coolify:
    • Backend: admin-brinxx.magiceverse.online
    • Storefront: brinxx.magiceverse.online
  4. Coolify auto-configures Traefik routing + SSL
  5. Click Deploy

Repeat for all 8 tenants:

Coolify ProjectBackend DomainStorefront Domain
Developmentadmin-development.magiceverse.onlinedevelopment.magiceverse.online
Brinxxadmin-brinxx.magiceverse.onlinebrinxx.magiceverse.online
Defaultadmin-default.magiceverse.onlinedefault.magiceverse.online
Logohorlogeadmin-logohorloge.magiceverse.onlinelogohorloge.magiceverse.online
Bovisalesadmin-bovisales.magiceverse.onlinebovisales.magiceverse.online
Demoadmin-demo.magiceverse.onlinedemo.magiceverse.online
De Sluisadmin-desluis.magiceverse.onlinedesluis.magiceverse.online
Jodasignadmin-jodasign.magiceverse.onlinejodasign.magiceverse.online

PIM — Project: “Magic PIM”

  1. Docker Compose from /mnt/data/magic_pim/
  2. Domain: pim.magiceverse.online

Spranz — Project: “Spranz”

  1. Docker Compose from /mnt/data/magic_omniverse/magic_commerce/magic_spranz/
  2. Domains: backend + storefront

n8n — Project: “Automation”

  1. Add 3 Docker Compose services from /mnt/data/magic_omniverse/magic_n8n/, magic_n8n_2/, magic_n8n_3/
  2. Domains: n8n.magiceverse.online, n8n2.magiceverse.online, n8n3.magiceverse.online

Vaultwarden — Project: “Tools”

  1. Add Service → Docker Image → vaultwarden/server:latest
  2. Mount volume: /mnt/data/vaultwarden:/data/
  3. Domain: vault.magiceverse.online

Sshwifty — same project “Tools”

  1. Docker Compose from /mnt/data/magic_omniverse/magic_terminal/
  2. Domain: terminal.magiceverse.online

rembg + preflight — same project “Tools”

  1. Docker Compose from /opt/services/rembg-service/ and /opt/services/preflight-service/

Magic Portal — Project: “Portal”

  1. Docker Compose from /mnt/data/magic_omniverse/magic_portal/
  2. Domain: portal.magiceverse.online

Dashboard — same project “Portal”

  1. Docker Compose from /mnt/data/magic_omniverse/magic_dashboard/
  2. Domain: dashboard.magiceverse.online

DevDocs (Starlight) — same project “Portal”

  1. Static site from /mnt/data/magic_omniverse/magic_docs/starlight/
  2. Build command: npm run build → serve dist/ directory
  3. Domain: devdocs.magiceverse.online

Nextcloud + OnlyOffice (optional) — Project: “Office”

  1. Docker Compose (separate from docs — Nextcloud has its own compose config)
  2. Domain: office.magiceverse.online

In the Coolify dashboard, check that all services show green/healthy status:

Shared Infrastructure
├── PostgreSQL 16 ✅ Healthy
└── MySQL 8 ✅ Healthy
Brinxx
├── Backend ✅ Running → admin-brinxx.magiceverse.online
├── Storefront ✅ Running → brinxx.magiceverse.online
├── Redis ✅ Healthy
└── Meilisearch ✅ Healthy
... (repeat for all tenants)
Magic PIM
├── Backend ✅ Running → pim.magiceverse.online
└── Redis ✅ Healthy
Automation
├── n8n ✅ Running → n8n.magiceverse.online
├── n8n_2 ✅ Running → n8n2.magiceverse.online
└── n8n_3 ✅ Running → n8n3.magiceverse.online
Portal
├── Magic Portal ✅ Running → portal.magiceverse.online
├── Dashboard API ✅ Running → dashboard.magiceverse.online
└── DevDocs (Starlight) ✅ Running → devdocs.magiceverse.online
Tools
├── Vaultwarden ✅ Running → vault.magiceverse.online
├── Sshwifty ✅ Running → terminal.magiceverse.online
├── rembg-service ✅ Running
└── preflight-service ✅ Running

Phase 5B: Transition to Monorepo + Auto-Deploy (After Migration)

Section titled “Phase 5B: Transition to Monorepo + Auto-Deploy (After Migration)”

Once Phase 5A is stable and DNS is switched, transition to the monorepo architecture for git-push deploys and preview environments.

Terminal window
# Create new GitHub repo: magic-commerce (private)
gh repo create magic-commerce --private
# Structure (see Monorepo + Coolify Plan for full details):
magic-commerce/
├── backend/ # Single Medusa codebase for all tenants
├── storefront/ # Single Next.js codebase for all tenants
├── tenants/ # Reference configs per tenant (tenant.json)
└── docker-compose.yml # Local development
  1. Copy magic_development/backend/ as the single backend codebase
  2. Replace hardcoded values with environment variables
  3. Implement feature flag system (backend/src/features/)
  4. Set up theme system for storefronts (storefront/src/themes/)
  5. Push to main branch

For each tenant, switch the source from local docker-compose to the GitHub monorepo:

  1. In Coolify → Project: Brinxx → Backend
  2. Change source: GitHub → magic-commerce repo → Build Path: /backend
  3. Set environment variables:
TENANT_ID=brinxx
TENANT_TIER=enterprise
TENANT_FEATURES=technique_pricing,brand_wizard
DATABASE_URL=postgres://postgres:xxx@postgres:5432/magic_b2b_brinxx
MEDUSA_BACKEND_URL=https://admin-brinxx.magiceverse.online
IMAGE_BASE_URL=https://images.magiceverse.online
  1. Enable Auto-deploy on push to main
  2. Enable Preview deployments on pull requests

Repeat for all tenants — same repo, different env vars.

  1. In Cloudflare DNS: add wildcard record *.preview.magiceverse.online → <netcup-ip>
  2. In Coolify: enable PR previews per project
  3. Flow:
Developer opens PR #42
Coolify auto-builds preview
Available at: pr-42.preview.magiceverse.online
PR merged → preview auto-destroyed
PR closed → preview auto-destroyed
Developer pushes to main
Coolify detects push (GitHub webhook)
Builds new Docker images for each tenant
Zero-downtime container replacement
Database migrations run automatically
✅ Live on production domains

Phase 6: DNS Switch (The Actual Migration Moment)

Section titled “Phase 6: DNS Switch (The Actual Migration Moment)”

On your local machine, temporarily point domains to the new server:

Terminal window
# Add to /etc/hosts (your laptop/desktop)
<netcup-ip> admin-development.magiceverse.online
<netcup-ip> admin-brinxx.magiceverse.online
<netcup-ip> brinxx.magiceverse.online
<netcup-ip> pim.magiceverse.online
# Test in browser — verify everything works
# Remove /etc/hosts entries after testing

6.2 Lower DNS TTL (Do 1 Hour Before Switch)

Section titled “6.2 Lower DNS TTL (Do 1 Hour Before Switch)”

In Cloudflare DNS dashboard:

  • Set TTL of all A records to 1 minute (60 seconds)
  • Wait 1 hour for old TTL to expire
Terminal window
# On old server — last sync before switch
docker exec magic_pim_postgres_dev pg_dumpall -U postgres > /tmp/postgres_final.sql
docker exec mysql mysqldump -uroot -p<your-db-password> --all-databases > /tmp/mysql_final.sql
scp /tmp/postgres_final.sql /tmp/mysql_final.sql root@<netcup-ip>:/tmp/
# Restore on new server (use actual Coolify container names from `docker ps`)
ssh root@<netcup-ip>
docker exec -i <coolify-postgres-container> psql -U postgres < /tmp/postgres_final.sql
docker exec -i <coolify-mysql-container> mysql -uroot -p<password> < /tmp/mysql_final.sql

In Cloudflare DNS dashboard, update all A records:

RecordOld ValueNew Value
*.magiceverse.online<old-server-ip><netcup-ip>
portal.magiceverse.online<old-server-ip><netcup-ip>
pim.magiceverse.online<old-server-ip><netcup-ip>

Coolify/Traefik automatically provisions Let’s Encrypt SSL certificates once DNS points to the server. Check the Coolify dashboard to confirm all domains show a valid certificate.

After confirming everything works (give it 1 hour):

  • Set TTL back to Auto or 3600 (1 hour)

Terminal window
# Run this verification script on the new server
echo "=== Container Status ==="
docker ps --format "table {{.Names}}\t{{.Status}}" | sort
echo ""
echo "=== Memory Usage ==="
free -h
echo ""
echo "=== Disk Usage ==="
df -h /
echo ""
echo "=== Container Count ==="
echo "Running: $(docker ps -q | wc -l)"
echo "Stopped: $(docker ps -aq --filter 'status=exited' | wc -l)"
ServiceURLWhat to Check
Development Adminhttps://admin-development.magiceverse.onlineLogin works, products load
Brinxx Adminhttps://admin-brinxx.magiceverse.onlineLogin, orders visible
Brinxx Storefronthttps://brinxx.magiceverse.onlineProducts display, images load from R2
All other tenantshttps://admin-{tenant}.magiceverse.onlineBasic login test
PIMhttps://pim.magiceverse.onlineProduct list loads
n8nhttps://n8n.magiceverse.onlineWorkflows visible
Vaultwardenhttps://vault.magiceverse.onlineLogin works
Terminalhttps://terminal.magiceverse.onlineSSH connection works
Portalhttps://portal.magiceverse.onlineDashboard loads, tenant list visible
Dashboardhttps://dashboard.magiceverse.onlineAPI responds, dev projects accessible
DevDocshttps://devdocs.magiceverse.onlinePages load
Product imageshttps://images.magiceverse.online/spranz/5325-00.001.jpgImage loads
Terminal window
# Install monitoring
apt install -y glances
# Watch live
glances
# Check Docker logs for errors
docker ps -q | xargs -I {} docker logs {} --tail=5 2>&1 | grep -i "error\|fatal\|crash"

  • Keep running for 1 week as fallback
  • After 1 week with no issues, stop all Docker containers
  • Keep database dumps as archive
  • Repurpose server for local dev / Omada Controller only
Terminal window
# Clean up Docker build cache
docker builder prune -f
# Remove dangling images
docker image prune -f
# Check final disk usage
df -h /

On your local machine (~/.ssh/config):

Host magic-cloud
HostName <netcup-ip>
User root
IdentityFile ~/.ssh/id_ed25519
StrictHostKeyChecking no

Update the server documentation to reflect the new infrastructure — IP addresses, paths, and connection details.


# Daily database backup script — /opt/scripts/backup-databases.sh
#!/bin/bash
BACKUP_DIR="/opt/backups/databases"
DATE=$(date +%Y%m%d_%H%M)
mkdir -p $BACKUP_DIR
# PostgreSQL
docker exec magic_pim_postgres_dev pg_dumpall -U postgres | gzip > $BACKUP_DIR/postgres_$DATE.sql.gz
# MySQL
docker exec mysql mysqldump -uroot -p<your-db-password> --all-databases | gzip > $BACKUP_DIR/mysql_$DATE.sql.gz
# Keep last 7 days
find $BACKUP_DIR -name "*.sql.gz" -mtime +7 -delete
echo "Backup completed: $DATE"
# Rsync to Hetzner Storage Box — /opt/scripts/backup-remote.sh
#!/bin/bash
STORAGEBOX="uXXXXXX@uXXXXXX.your-storagebox.de"
# Sync database backups
rsync -avz --delete /opt/backups/databases/ $STORAGEBOX:backups/databases/
# Sync docker-compose files and configs
rsync -avz --delete /mnt/data/magic_omniverse/magic_commerce/*/docker-compose*.yml $STORAGEBOX:backups/compose/
rsync -avz --delete /opt/coolify/ $STORAGEBOX:backups/coolify/
echo "Remote backup synced: $(date)"
Terminal window
# /etc/crontab additions
# Local DB backup — every 6 hours
0 */6 * * * root /opt/scripts/backup-databases.sh >> /var/log/backup.log 2>&1
# Remote sync — daily at 3 AM
0 3 * * * root /opt/scripts/backup-remote.sh >> /var/log/backup-remote.log 2>&1

Product images in Cloudflare R2 are the source of truth. R2 has built-in durability (99.999999999%). For extra safety:

Terminal window
# Weekly sync of R2 to Hetzner Storage Box
0 4 * * 0 root rclone sync r2:magic-pim-images/ /tmp/r2-mirror/ && rsync -avz --delete /tmp/r2-mirror/ $STORAGEBOX:backups/images/ && rm -rf /tmp/r2-mirror/

WhenActionNew SpecsPrice
CurrentRS 4000 G1212 cores, 32 GB, 1 TB€27/mo
Need more diskAdd Netcup Block Storage+500 GB NVMe+€5.95/mo
Need more RAM/CPUMigrate to RS 8000 G1216 cores, 64 GB, 2 TB€58/mo
High trafficAdd Cloudflare CDN (free)Cache storefronts at edge€0
Need HA/scalingMigrate to Hetzner CloudCCX series + load balancerVariable