Today I’m releasing CloudArch, a set of Docker Compose packs for running your own services at home or on a small VPS. A reverse proxy as the front door, Nextcloud, Immich and Vaultwarden behind it, encrypted backups that are actually restore-tested, and monitoring that tells you when something is about to break.
This post isn’t a feature list. I’m a Lead Data Engineer (Linux certified, Spanish, based in Kraków), and the habits of that job shaped these packs: pin everything, validate before you run, and assume the restore is the part that fails. Below is how they’re built, with real excerpts from the files you’d get, the bugs testing found, and an honest list of what has not been tested yet.
The shape of the system
There are four packs. Three of them are designed to plug together; the fourth is a standalone LAN-only edition.
┌──────────── Secure Gateway ────────────────────────────┐
Internet ─► (A) Cloudflare edge ══ tunnel ══► cloudflared ─┐ │
─► (C) VPS :80/:443 ─────────────────────────────┤ │
Tailnet ─► (B) 100.x.y.z:443 ────────────────────────────┤ │
▼ │
Caddy (ca-caddy) ── access log ──► CrowdSec
│ ▲ metrics :9180 │ │
│ │ │ └─(C) nftables bouncer
docker network "cloudarch_proxy" │ │ │
─────────────────────────────────┼────┼──────────────────────────────┘
│ │ │ │ │
Homepage Uptime Kuma Nextcloud Immich Vaultwarden Grafana ◄─ Prometheus ◄─ exporters
│ │ (Monitoring pack) node, cAdvisor,
internal network "backend" │ │ blackbox, SMART
(no route out) ────────────┼───────┼───────────
Postgres+Redis Postgres(VectorChord)+Valkey, ML
restic (nightly) ── DB dumps + files ──► B2 / S3 / SFTP / disk
└─ writes cloudarch_backup_*.prom ──► node_exporter textfile ──► backup alerts
- Secure Gateway is the only pack that owns ports. Everything else joins its Docker network
cloudarch_proxyand drops a*.caddyfile intocaddy/sites/. - Private Cloud runs the apps and the backups.
- Monitoring & Alerts scrapes everything, including backup freshness.
- Private Cloud Home is the same three apps with its own Caddy, bound only to your LAN IP, for people who don’t want a domain or any internet exposure.
Ground rules applied to every container
First, the boring rules every pack follows. They’re the reason the rest works.
Pinned versions. No latest anywhere. From the release changelogs:
| Component | Version |
|---|---|
| Caddy | 2.11.7 (optional build with caddy-dns/cloudflare v0.2.4) |
| cloudflared | 2026.10.0 |
| CrowdSec | 1.8.1 |
| Homepage / Uptime Kuma | 2.5.0 / 2.5.6 |
| Nextcloud / PostgreSQL / Redis | 35.0.1 / 16.15 / 8.8.3 |
| Immich / Valkey | 3.3.1 / 9.1.2 (Immich’s Postgres image pinned by digest) |
| Vaultwarden | 1.37.4 |
| restic | 0.19.1 |
| Prometheus / Alertmanager / Grafana | 3.15.0 / 0.34.1 / 13.2.3 |
| node_exporter / cAdvisor / blackbox / smartctl_exporter | 1.12.1 / 0.60.6 / 0.29.0 / 0.15.0 |
All of those tags were checked with docker manifest inspect to exist for both amd64 and arm64.
Shared defaults via a YAML anchor at the top of each compose file:
x-defaults: &defaults
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
Log rotation is there because unbounded json-file logs are a common way for a small box to fill its disk. The Gateway’s anchor also sets no-new-privileges for every container; in the app packs, Vaultwarden gets it explicitly.
Health checks on everything, and depends_on: condition: service_healthy where order matters. Nextcloud waits for Postgres and Redis to be healthy. cloudflared waits for Caddy. The Nextcloud health check doesn’t just test that Apache answers; it checks that the instance reports itself as installed:
test: ["CMD-SHELL", "curl -fsS -H 'Host: cloud.${DOMAIN}' http://127.0.0.1/status.php | grep -q '\"installed\":true'"]
Memory (and some CPU) limits per container, so one runaway process can’t take the others down. More on the numbers below.
Idempotent installers. Each install.sh checks Docker/Compose, RAM, free disk and port conflicts. It creates .env only if it doesn’t exist and generates random secrets only for empty values. It validates the config (docker compose config, caddy validate, and for monitoring promtool and amtool) before starting, then waits for every health check. Re-running it after editing .env converges to the new state without touching your passwords.
The front door: three ingress variants, one Caddyfile
Most self-hosting guides pick one way in. The Gateway supports three, selected with ./install.sh --variant A|B|C, and the same Caddyfile serves all of them. The trick is a small set of TLS snippets chosen by an environment variable:
(tls_auto) {
}
(tls_internal) {
tls internal
}
(tls_off) {
}
(tls_dns) {
tls {
dns cloudflare {env.CF_API_TOKEN}
resolvers 1.1.1.1
}
}
(common) {
import tls_{$TLS_MODE}
encode zstd gzip
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
X-Frame-Options SAMEORIGIN
-Server
}
log {
output file /var/log/caddy/access.log {
roll_size 20MiB
roll_keep 5
}
format json
}
}
{$SITE_SCHEME}status.{$DOMAIN} {
import common
reverse_proxy ca-uptime-kuma:3001
}
import /etc/caddy/sites/*.caddy
SITE_SCHEME is either empty (Caddy’s automatic HTTPS applies) or http:// (Caddy serves plain HTTP for that site). The installer sets TLS_MODE, SITE_SCHEME and the bind addresses per variant:
case $v in
A) env_set TLS_MODE off; env_set SITE_SCHEME "http://"; env_set HTTP_BIND 127.0.0.1; env_set HTTPS_BIND 127.0.0.1
...
PROFILES=(--profile tunnel);;
B) env_set SITE_SCHEME ""
# CF API token given -> TLS_MODE=dns, otherwise TLS_MODE=internal
ip=$(command -v tailscale >/dev/null && tailscale ip -4 2>/dev/null | head -1 || true)
[[ -n $ip ]] || die "Tailscale not running. ..."
env_set HTTP_BIND "$ip"; env_set HTTPS_BIND "$ip"; PROFILES=();;
C) env_set TLS_MODE auto; env_set SITE_SCHEME ""; env_set HTTP_BIND 0.0.0.0; env_set HTTPS_BIND 0.0.0.0
# compares public IP with DNS for home./status. and warns before certificates are attempted
PROFILES=();;
esac
and the compose file publishes ports only on those addresses:
ports:
- "${HTTP_BIND:-0.0.0.0}:80:80"
- "${HTTPS_BIND:-0.0.0.0}:443:443"
- "${HTTPS_BIND:-0.0.0.0}:443:443/udp"
Variant A: Cloudflare Tunnel (no open router ports)
cloudflared lives behind a Compose profile, so it only exists when you pick variant A:
cloudflared:
<<: *defaults
profiles: ["tunnel"]
image: cloudflare/cloudflared:2026.10.0
command: tunnel --no-autoupdate --metrics 0.0.0.0:2000 run
environment:
TUNNEL_TOKEN: ${CF_TUNNEL_TOKEN:-}
networks: [proxy]
healthcheck:
test: ["CMD", "cloudflared", "tunnel", "--metrics", "127.0.0.1:2000", "ready"]
depends_on:
caddy: { condition: service_healthy }
In the Cloudflare dashboard every public hostname (or one wildcard) points to HTTP ca-caddy:80. Cloudflare terminates TLS at its edge, the tunnel carries the request, and Caddy serves plain HTTP inside the Docker network. Caddy’s ports are bound to 127.0.0.1 only, so nothing is reachable from your LAN or router.
The trade-offs, which the guide spells out:
- Cloudflare terminates TLS, so it can see the traffic.
- The Free plan limits request bodies to 100 MB. Nextcloud’s clients upload in chunks, so that’s fine. The Immich mobile app uploads whole files, so large videos fail through the tunnel. The guide suggests Tailscale or LAN for those uploads.
- Admin UIs (
status.,grafana.) should sit behind Cloudflare Access with an email one-time code.
Variant B: Tailscale with real certificates via DNS-01
Variant B binds Caddy to the server’s Tailscale address (100.x.y.z). Your DNS records point there too ("DNS only", not proxied), so the hostnames resolve publicly but only devices in your tailnet can reach them.
The interesting part is certificates. Let’s Encrypt can’t do an HTTP-01 challenge against a private IP, so the pack builds a Caddy image with the Cloudflare DNS plugin and uses DNS-01:
FROM caddy:2.11.7-builder-alpine AS builder
RUN xcaddy build v2.11.7 --with github.com/caddy-dns/cloudflare@v0.2.4
FROM caddy:2.11.7-alpine
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
The installer builds this once (as cloudarch/caddy-dns:2.11.7) when you give it a Cloudflare API token scoped to Edit zone DNS on one zone. Without a token it falls back to tls internal, and you install Caddy’s root CA on your devices. The guide warns that some Android apps, Bitwarden included, ignore user-installed CAs, so for Vaultwarden the DNS method is the one to use.
Variant C: a VPS with a public IP
The classic setup: ports 80/443 open on the VPS, TLS_MODE=auto, and Caddy gets Let’s Encrypt certificates on its own. Before starting, the installer compares the server’s public IPv4 with what home. and status. resolve to and warns if they don’t match, so you find a DNS mistake before you burn through Let’s Encrypt’s rate limits.
Real client IPs and CrowdSec
Behind a tunnel or another proxy, every request appears to come from the proxy. That makes access logs useless and IP-based banning dangerous. The Gateway’s global options handle it:
servers {
trusted_proxies static private_ranges 173.245.48.0/20 103.21.244.0/22 ... 131.0.72.0/22
client_ip_headers CF-Connecting-IP X-Forwarded-For
}
CF-Connecting-IP is only trusted when the request comes from private ranges (where cloudflared sits) or Cloudflare’s published ranges. Caddy writes JSON access logs to a shared volume. CrowdSec reads them through a one-line acquisition config (/var/log/caddy/access.log, type: caddy) with the crowdsecurity/caddy, crowdsecurity/http-cve and crowdsecurity/whitelist-good-actors collections.
Here’s where I want to be precise, because "CrowdSec included" can mean a lot of things:
- Detection runs in every variant: scanners, brute force and CVE probes become decisions (
cscli decisions list). - Enforcement ships for variant C.
scripts/install-firewall-bouncer.shinstallscrowdsec-firewall-bouncer-nftableson the host, registers it with the CrowdSec API (bound to127.0.0.1:8080only), and bans IPs in nftables. - In variants A and B there’s no bouncer in the pack. In A, attackers don’t connect to your host at all (they hit Cloudflare), so the guide points you to Cloudflare Access and WAF rules instead. In B, only your own tailnet devices can reach Caddy in the first place.
Private Cloud: isolation and limits
The Private Cloud compose file defines two networks:
networks:
proxy:
name: cloudarch_proxy
external: true
backend:
internal: true # databases are never reachable from the proxy network / internet
Postgres, Redis, Valkey, the Immich ML container and the Nextcloud cron container live only on backend. An internal: true network has no route out, and Caddy isn’t on it, so a compromised proxy can’t even open a TCP connection to a database. Nextcloud and Immich sit on both networks. Vaultwarden, which uses SQLite and has no database container, sits only on proxy, with no-new-privileges and signups off by default (install.sh --lock-signups closes them after you create your account).
The default limits fit an 8 GB machine and are all overridable in .env:
| Container | Memory | CPU |
|---|---|---|
| Nextcloud | 2 G | 2 |
| Nextcloud Postgres | 512 M | — |
| Immich server | 3 G | 3 |
Immich machine learning (profile ml) |
2 G | 2 |
| Immich Postgres | 1 G | — |
| Redis / Valkey / Vaultwarden | 256 M each | — |
| Nextcloud cron | 512 M | — |
Immich’s face and object recognition sits behind a Compose profile (COMPOSE_PROFILES=ml). On a 4 GB machine you remove it and keep everything else. An existing photo library can be mounted read-only (${EXTERNAL_PHOTOS_PATH}:/mnt/external-photos:ro) into both Immich and Nextcloud, so neither app can modify your originals.
Backups: designed around the restore
The backup script runs daily from a systemd timer (03:30 by default) and does six things in order:
- Consistent database dumps. Nextcloud goes into maintenance mode, then both Postgres databases are dumped in custom format:
An$DOCKER exec -u www-data ca-nextcloud php occ maintenance:mode --on $DOCKER exec ca-nextcloud-db pg_dump -U nextcloud -d nextcloud -Fc | sudo tee "$ST/nextcloud.dump" $DOCKER exec ca-immich-db pg_dump -U immich -d immich -Fc | sudo tee "$ST/immich.dump"ERRtrap turns maintenance mode back off if anything fails, so a broken backup doesn’t leave Nextcloud locked. - Vaultwarden snapshot. The container is stopped for a few seconds, its data directory (with the SQLite database) is copied to staging, and it starts again.
- restic backup of the data root. The live database directories are excluded (
/data/nextcloud/db,/data/immich/db): copying Postgres files while it runs gives you a backup that may not start, which is why the dumps exist. Caches, the ML model cache and, optionally, Immich’s regenerablethumbs/andencoded-video/are excluded too. - Retention with
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune(configurable). - Integrity check.
restic checkevery day, and on Sundaysrestic check --read-data-subset=5%, which actually downloads and verifies a sample of the data. - Metrics. The script writes
cloudarch_backup_last_status,..._last_success_timestamp_seconds,..._duration_secondsand..._repo_size_bytesto node_exporter’s textfile directory, so the Monitoring pack can alert on them.
restic itself runs as a pinned container (restic/restic:0.19.1), so there’s nothing to install on the host. The repository can be Backblaze B2 or any S3 endpoint, an SFTP server, or a local/USB disk. A flock prevents two runs overlapping. The restic password is generated by the installer, and the .env comment is blunt about it: lose it = lose backups.
The restore drill
A backup you’ve never restored is a hope, not a backup. scripts/restore-drill.sh proves restorability without touching live data:
- restores the latest snapshot into a temporary directory;
- checks that the two dumps, Vaultwarden’s
db.sqlite3and Nextcloud’sconfig.phpare present and non-empty; - compares restored and live file counts;
- starts throwaway Postgres containers (the Immich one uses the exact image the live Immich DB runs), loads each dump with
pg_restore, and requires more than 10 tables in the public schema; - runs SQLite’s
pragma integrity_checkon the Vaultwarden database; - prints
RESTORE DRILL PASSEDor exits withRESTORE DRILL FAILED — fix before you need it!, and on success writescloudarch_restore_drill_last_success_timestamp_seconds.
The Monitoring pack’s RestoreDrillOverdue alert fires if no drill has passed in 35 days. That turns "I should test my backups" into a notification.
Disaster recovery
scripts/restore.sh [snapshot|latest] is the real thing for a dead or new machine. It asks you to type RESTORE, stops the stack, restores the snapshot to a temp directory next to the data root, and moves the file trees into place. Then it starts only the two database containers, loads the dumps into empty databases, starts everything, waits up to 10 minutes for health checks, turns maintenance mode off and runs occ files:scan --all.
The bugs testing found (and what changed in 1.0.1)
I tested the disaster recovery path end-to-end before release, and it was broken. This is exactly why I wanted a test, and it’s why Private Cloud is 1.0.1 on day one.
- Nextcloud’s database role didn’t exist on a fresh Postgres. Nextcloud’s installer creates its own DB role (usually
oc_admin), and objects in the dump are owned by it. On a brand-new database that role doesn’t exist, sopg_restorefailed and Nextcloud couldn’t log in to its DB.restore.shnow readsdbuser/dbpasswordfrom the restoredconfig.phpand re-creates the role before loading the dump, keeping object ownership intact:DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'oc_admin') THEN CREATE ROLE "oc_admin" LOGIN CREATEDB; END IF; END $$; ALTER ROLE "oc_admin" WITH PASSWORD '...'; - Immich crash-looped after restore (
verifyReadAccess). Immich expects a.immichmarker file in each media folder.thumbs/andencoded-video/are excluded from backups by default, so after a restore they didn’t exist. The script now recreates the folders and markers. The Immich dump is also restored with--no-privilegesto avoid spurious grant errors.
Earlier, the first backup test found a third problem: restic’s --one-file-system flag silently skipped every file under the container bind mounts. The backup "succeeded" and contained almost nothing. The flag is gone, and the drill now prints restored vs. live file counts, which makes that kind of bug visible.
The Home edition’s testing found the same two restore bugs plus three of its own: the installer misread Docker’s port-range output on re-run, CA rotation also needed to delete issued leaf certificates, and a restic repository located inside the data root is now excluded from its own backup. All fixed before release.
After the fix, the 1.0.1 verification run went like this: gateway + Private Cloud installed, a marker file uploaded over WebDAV, backup.sh, restore-drill.sh → PASSED, restore.sh latest → exit 0 with "database role oc_admin re-created". All three apps came back healthy, Nextcloud was installed and out of maintenance mode, and the marker file’s content was identical.
Private Cloud Home: LAN-only, with a local CA
Some people don’t want a domain, a Cloudflare account or anything reachable from outside. The Home edition is standalone: same apps, same backup/drill/DR scripts, plus its own Caddy, which refuses to listen anywhere but your LAN.
The installer auto-detects the LAN IP and enforces the boundary:
case $ip in 10.*|192.168.*|172.1[6-9].*|172.2[0-9].*|172.3[01].*|100.6[4-9].*|...|100.12[0-7].*) ;;
0.0.0.0) die "BIND_IP=0.0.0.0 would listen on every interface (incl. public/VPN). Use the LAN IP.";;
*) die "BIND_IP=$ip is not a private LAN address (RFC 1918/6598). This pack is LAN-only ...";; esac
It also rejects .local as the LAN domain (reserved for mDNS; home.lan is the default). If DHCP has moved the server to a new address, a re-run notices and follows it, with a warning to reserve a fixed IP.
HTTPS comes from a Caddy-managed private CA. The installer generates the Caddyfile, including:
{
admin 127.0.0.1:2019
local_certs
default_sni 192.168.1.20
auto_https disable_redirects
pki {
ca local {
name "CloudArch Home Local CA"
}
}
}
Every app is reachable two ways: by name (https://cloud.home.lan, photos., vault.) or with no DNS at all at https://<server-ip>:8443/8444/8445. Plain http://<server-ip>/ serves a small page for downloading the root certificate in PEM (Android, Linux, macOS, iOS) and DER (Windows), with its SHA-256 fingerprint. For name resolution the installer writes ready-made snippets for a hosts file, dnsmasq/Pi-hole, the Pi-hole v6 UI and AdGuard Home. The CA key lives under the data root and is included in backups; DR testing confirmed the CA fingerprint survives a full restore. scripts/rotate-ca.sh replaces it if you ever need to.
One caveat that I put in the script itself: Docker-published ports bypass ufw. The real protection is binding to the LAN IP and not forwarding ports on your router. firewall-ufw.sh --apply adds ufw rules for your subnet plus a DOCKER-USER iptables guard that drops non-LAN connections to the bind IP. Run without --apply, it just prints the rules so you can review them.
Monitoring & Alerts
The Monitoring pack runs Prometheus, Alertmanager and Grafana with node_exporter (host), cAdvisor (containers), blackbox (HTTPS probes of your subdomains) and, behind a smart profile for bare metal, smartctl_exporter. Prometheus and Alertmanager are published on 127.0.0.1 only; Grafana is reached through the Gateway at grafana.<domain>. Retention defaults to 30 days / 5 GB.
Grafana gets five provisioned dashboards (overview, host, containers, disks & SMART, backups & certificates) and 22 alert rules in five groups:
- host: disk almost full, disk full, disk will fill in 24 h, high temperature, high memory, high load, rebooted
- SMART: failing disk, hot disk, reallocated sectors, NVMe media errors
- containers: down, restarting, near memory limit, scrape target down
- endpoints & certificates: endpoint down, cert expiring soon, cert expiry critical
- backups: stale, failed, metrics missing, restore drill overdue
Thresholds come from .env and are templated into the rules at install time. A few examples:
- alert: DiskWillFillIn24h
expr: predict_linear(node_filesystem_avail_bytes{fstype!~"tmpfs|overlay|squashfs|ramfs"}[6h], 24*3600) < 0
and node_filesystem_avail_bytes{...} / node_filesystem_size_bytes{...} < 0.4
for: 30m
- alert: ContainerNearMemoryLimit
expr: (container_memory_working_set_bytes{name=~"ca-.+"} / (container_spec_memory_limit_bytes{name=~"ca-.+"} > 0)) * 100 > 90
for: 15m
- alert: BackupStale
expr: time() - cloudarch_backup_last_success_timestamp_seconds > __BACKUP_MAX_AGE_HOURS__ * 3600 # 36 h default
for: 15m
labels: { severity: critical }
- alert: CertExpiringSoon
expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 < __CERT_WARN_DAYS__ # 14 days default
annotations:
description: "Caddy renews automatically ~30 days before expiry; if you see this, renewal is failing: docker logs ca-caddy | grep -i acme"
The memory-limit alert is why the limits table above matters: per-container limits plus cAdvisor give you an early warning before the OOM killer acts. The and ... < 0.4 clause on the 24-hour prediction stops it firing on a mostly-empty disk during a big copy.
Alertmanager is rendered from .env into one receiver with email, Telegram and/or ntfy, depending on which you fill in. It groups by alertname, instance, repeats warnings every 4 h and criticals every hour. Inhibit rules mute DiskAlmostFull while DiskFull is firing, and CertExpiringSoon while CertExpiryCritical is. The installer validates rules with promtool and the config with amtool before anything starts. scripts/test-alert.sh sends a test alert so you can see it arrive.
What was tested, and what wasn’t
Everything above was tested on a clean amd64 machine (Docker 26.1.5, Compose 5.6.0) using each installer’s --test mode: a local test domain, Caddy’s internal CA, throwaway data. In summary:
Passed
- All 21 image tags exist for amd64 and arm64.
- Gateway: Caddy, Homepage, Uptime Kuma and CrowdSec healthy; sites served through Caddy. Caddyfile valid in all four TLS modes. The custom Caddy build with the Cloudflare DNS plugin builds.
- Private Cloud: 9 containers healthy, Nextcloud 35.0.1 installed, all three apps reachable through the gateway.
- Backup to a local restic repo, retention,
restic check. - Restore drill: Nextcloud dump → 137 tables, Immich dump → 72 tables, Vaultwarden SQLite integrity OK.
- Full DR with
restore.sh(1.0.1) as described above. - Monitoring:
promtool/amtoolvalidation, 6 containers healthy, all targets up, 22 rules loaded, 5 dashboards provisioned, a test alert reaching Alertmanager. An Alertmanager config with email + Telegram + ntfy rendered and validated. Blackbox probes of all 6 subdomains succeed. - Home: 10 containers healthy, Caddy bound only to the LAN IP, strict TLS verification with the exported root (rejected without it), a 150 MB WebDAV upload, database unreachable from Caddy, backups to two targets, DR with the CA fingerprint unchanged, CA rotation, and refusal of
8.8.8.8,0.0.0.0and*.local. - Idempotent re-runs (the Home
.envmd5 is unchanged), all uninstallers, andshellcheck -S erroron all scripts.
Not tested yet
- Real Let’s Encrypt issuance, a live Cloudflare Tunnel connection and Tailscale (no public domain or accounts in the test environment).
- Running on arm64 hardware. The images are verified multi-arch, but I haven’t published results from a Raspberry Pi or ARM VPS run.
- SMART on physical disks, and actual delivery to Telegram, ntfy or email inboxes.
- The systemd backup timer, applying ufw/
DOCKER-USERrules (they were only printed), SFTP/B2/NAS backup targets, and real devices trusting the Home CA. - CrowdSec’s hub download (the test containers had no outbound internet).
I’d rather tell you this list than have you discover it. As I validate these paths on real hardware and accounts, the fixes will ship as 1.x updates, which are free for existing buyers.
Hardware and requirements
From the guides:
| Pack(s) | Minimum | Comfortable |
|---|---|---|
| Secure Gateway only | 1 core, 1 GB RAM, 10 GB disk | 2 cores, 2 GB |
| Gateway + Monitoring | 2 cores, 2 GB RAM, 20 GB | 2 cores, 4 GB |
| Gateway + Private Cloud | 4 cores, 4 GB RAM (no face recognition) | 4+ cores, 8 GB, SSD for system + large disk for data |
| All three packs | 4 cores, 8 GB RAM | 6+ cores, 16 GB |
The installers warn below 1 GB RAM / 10 GB disk (Gateway, Monitoring) and 4 GB RAM / 50 GB disk (Private Cloud, Home). Monitoring itself needs about 500 MB of RAM.
You’ll need 64-bit Linux (Debian 12/13, Ubuntu 24.04 LTS or Raspberry Pi OS 64-bit are the targets; 32-bit isn’t supported) and Docker Engine 24+ with Compose v2. Put databases on an SSD, never an SD card. For variants A/B you also need a domain on Cloudflare (or a Tailscale account for B); the Home edition needs neither. On the human side, you should be comfortable with SSH and editing a text file.
What the packs deliberately are not: Traefik or nginx (Caddy only), Kubernetes (Compose only), or Windows/macOS hosts (they work as clients). The open-source apps aren’t bundled; they’re pulled from their official images. What you get is the configuration, scripts, dashboards, rules and guides.
Getting the packs
Every pack comes with a step-by-step guide in English and Spanish (PDF + Markdown), the scripts described above, and free updates for all 1.x versions.
- Secure Gateway: 19 €. Caddy with the three ingress variants, CrowdSec (+ bouncer for VPS), Homepage, Uptime Kuma, installer. The base for the other packs.
- Private Cloud: 39 €. Nextcloud, Immich, Vaultwarden, restic backups, restore drill and DR script. Requires the Secure Gateway.
- Private Cloud Home: 24 € at launch. The LAN-only standalone edition with its own Caddy and local CA. No domain, no Gateway needed.
- Monitoring & Alerts: 29 €. Prometheus, Grafana with 5 dashboards, 22 alert rules, email/Telegram/ntfy. Requires the Secure Gateway.
- Bundle: 59 €. Secure Gateway + Private Cloud + Monitoring (87 € separately).
Everything is on the store page: cloudarch.gumroad.com. A small free, MIT-licensed "lite" version of the Gateway (Caddy + Uptime Kuma) is available on GitHub for anyone who wants to start there: cloudarch-gateway-lite.
If you have questions about any of the design decisions above, or you spot something I got wrong, I’d genuinely like to hear it.
