Why consolidate five services on a single VPS
The "one app, one VPS" approach has a logic: total isolation, independent deployment, no risk of contention. It also has a real cost — five servers, five IP addresses, five renewals, five nginx configurations, five TLS certificates to monitor. For a personal stack or a small team, this cost is only justified if the applications have very different peak loads or incompatible security requirements.
Nextcloud, Vaultwarden, Jellyfin, Immich and Paperless-ngx share the characteristic of being moderate-traffic applications, used primarily by one or a few users. Vaultwarden consumes less than 50 MB of RAM in idle mode. Paperless-ngx runs around 200 MB. Immich, the most resource-hungry at rest outside of transcoding, stays under 400 MB idle. These measurements are documented in the respective GitHub projects and in self-hosting community feedback.
Consolidation on a VPS does not eliminate risks — it concentrates them. The trade-off is a single entry point to harden, a single certificate to manage, and a versioned Docker Compose manifest that constitutes the complete documentation of the infrastructure.
What this stack delivers in practice
- Data sovereignty — files, passwords, photos and documents stay on your server, under your encryption key, with no dependency on a third-party cloud provider.
- A single wildcard TLS certificate — Caddy automatically requests and renews
*.your-domain.comvia the DNS-01 challenge, covering all stack subdomains in a single configuration. - Isolated internal Docker network — no application exposes a port publicly; all inbound traffic passes through Caddy on 80/443, and services communicate over a private bridge network.
- Named volumes and predictable restoration — each service declares its data in a named Docker volume (
nextcloud_data,vaultwarden_data…), making backups and migrations reproducible with a singlersynccommand. - Independent updates — pulling a new image for Immich does not restart Jellyfin or Paperless-ngx;
docker compose up -d --no-deps immichonly touches the relevant service. - Fixed and predictable cost — a fixed-resource VPS eliminates billing surprises generated by cloud services when Jellyfin transcoding spikes or Paperless-ngx OCR tasks accumulate.
- Possible service mesh — Nextcloud can use Redis and MariaDB already present in the Compose; Immich shares the same network as the reverse proxy without additional configuration.
Prerequisites: sizing and ports
The realistic floor for this stack in common use is 4 vCPU / 8 GB RAM / 100 GB SSD storage. This sizing covers the idle footprints of each service and leaves margin for on-demand Jellyfin transcoding and Paperless-ngx OCR tasks, which are the two non-trivial load spikes of the stack.
Measured idle footprints (no active session, no background task):
- Nextcloud (PHP-FPM + cron): ~300 MB depending on sync load.
- Vaultwarden: < 50 MB, very compact Rust image.
- Jellyfin: ~250 MB without active transcoding. In soft transcoding (x264, 1080p): 1 to 2 vCPU at peak.
- Immich (server + microservices): ~350-400 MB at rest. Machine learning tasks (face detection, classification) can consume up to 2 GB of RAM depending on volume.
- Paperless-ngx (web + worker): ~200 MB. Tesseract OCR on a 50-page PDF can temporarily saturate a vCPU.
- Caddy: < 30 MB.
- Databases (MariaDB for Nextcloud + Paperless, Redis): ~200 MB combined.
Estimated total at rest: ~1.6 GB out of the 8 GB available. The margin absorbs peaks and allows adding another service without resizing.
Ports to open on the firewall: 80/tcp and 443/tcp only. All other ports remain closed — internal services are not directly exposed.
Step-by-step deployment
Prepare the VPS
Connect via SSH to your VPS and update the system:
apt update && apt upgrade -y. Install Docker and the Compose plugin:curl -fsSL https://get.docker.com | sh. Verify the installation:docker compose version. Create a dedicated non-root user and add them to thedockergroup:adduser deploy && usermod -aG docker deploy. Enable UFW with minimal rules:ufw allow 22/tcp && ufw allow 80/tcp && ufw allow 443/tcp && ufw enable.Configure DNS
In your DNS zone, create an A record for the root domain pointing to your VPS IP, then CNAME or A subdomains for each service:
nextcloud.your-domain.com,vault.your-domain.com,jellyfin.your-domain.com,photos.your-domain.comanddocs.your-domain.com. If you use Caddy's DNS-01 challenge for the wildcard certificate, ensure your DNS provider has an API supported by the correspondingcaddy-dnsmodule. Wait for DNS propagation (a few minutes to hours depending on the configured TTL).Create the folder structure
Create the project tree on the VPS:
mkdir -p /opt/homelab/{caddy,nextcloud,vaultwarden,jellyfin,immich,paperless}. This directory will contain thedocker-compose.ymlfile, theCaddyfileand environment files. Persistent data will be stored in named Docker volumes, not bind-mounts, to simplify backups and avoid permission issues.Write the Caddyfile
In
/opt/homelab/caddy/Caddyfile, declare one block per subdomain. Example for Nextcloud:nextcloud.your-domain.com { reverse_proxy nextcloud:80 }. Repeat the pattern for each service pointing to the Docker service name (vaultwarden,jellyfin,immich-server,paperless-webserver). Caddy obtains and renews Let's Encrypt certificates automatically on first access. For a wildcard certificate, replace individual blocks with*.your-domain.comwith your provider's DNS module, configured via the environment variables of the custom Caddy image.Write the docker-compose.yml
Create
/opt/homelab/docker-compose.ymlwith a sharedproxynetwork and an isolatedinternalnetwork. Declare the Caddy service withports: ["80:80", "443:443"]andvolumes: ["./caddy/Caddyfile:/etc/caddy/Caddyfile", "caddy_data:/data"]. For each application, declarenetworks: [proxy, internal]and expose noports:— only Caddy exposes public ports. Usedepends_onwithcondition: service_healthyso Nextcloud does not try to join MariaDB before it is ready. Sensitive variables (database passwords, secret keys) go in a.envfile referenced byenv_file: .env.Configure environment variables
Create
/opt/homelab/.envwith variables required by each service:MYSQL_ROOT_PASSWORD,MYSQL_DATABASE,MYSQL_USER,MYSQL_PASSWORDfor MariaDB;NEXTCLOUD_ADMIN_USER,NEXTCLOUD_ADMIN_PASSWORD,NEXTCLOUD_TRUSTED_DOMAINSfor Nextcloud;VAULTWARDEN_ADMIN_TOKENfor Vaultwarden. Generate secrets withopenssl rand -hex 32. For Immich, copy the example.envfile from the official repository — it declares required variables and their defaults. Never commit this file to a public repository; add.envto your.gitignore.Launch the stack
From
/opt/homelab, rundocker compose pullto download all images, thendocker compose up -dto start everything. Follow startup logs withdocker compose logs -fto ensure each service starts without error. The first initialization of Nextcloud and Paperless-ngx may take a few minutes (database migrations, key generation). Caddy obtains TLS certificates on first access to each subdomain — verify that ports 80 and 443 are accessible from outside before testing.Finalize configuration for each service
Access each web interface to complete initial configuration: Nextcloud (
nextcloud.your-domain.com) to enable recommended apps (Contacts, Calendar, Talk); Immich (photos.your-domain.com) to configure libraries and enable background machine learning tasks; Paperless-ngx (docs.your-domain.com) to configure the document consumer and OCR; Jellyfin (jellyfin.your-domain.com) to point to media folders mounted as volumes. Vaultwarden (vault.your-domain.com) only requires account creation from the Bitwarden client — no initial server configuration needed.
Caddy, Nginx Proxy Manager or Traefik: which reverse proxy for this stack
Scroll the table
| Criterion | Caddy | Nginx Proxy Manager | Traefik |
|---|---|---|---|
| Configuration | Declarative Caddyfile, reload without downtime | Web interface, no files to edit | Docker labels, automatic hot-reload |
| Automatic SSL | Built-in, DNS-01 and HTTP-01 native | Let's Encrypt via interface, DNS-01 possible | Let's Encrypt via ACME resolver, DNS-01 via providers |
| Wildcard | Native via caddy-dns module | Possible but requires manual setup | Native via certificateResolvers |
| Learning curve | Low — Caddyfile readable in 10 minutes | Very low — everything is done by clicking | Medium — voluminous documentation |
| Suited to this stack | Yes — versioned config, live reload | Yes for getting started, less ideal for versioning | Yes for more complex stacks, config overhead here |
Minimal hardening before public exposure
Four measures to apply before making the stack accessible from outside:
1. Disable Vaultwarden's open registration page by setting SIGNUPS_ALLOWED=false in the .env once your account is created.
2. Add an X-Robots-Tag: noindex header in the Caddyfile for Vaultwarden and the Paperless-ngx admin interface — these pages should not be indexed.
3. Enable Docker log rotation (log-opts in /etc/docker/daemon.json) to prevent Jellyfin or Paperless-ngx logs from filling the disk.
4. Schedule a weekly docker compose pull && docker compose up -d via cron to keep images up to date — self-hosted applications regularly publish security patches. Check changelogs before updating Immich, which may introduce non-reversible database migrations.
Troubleshooting: common startup errors
Error response from daemon: network proxy declared as external, but could not be found — This message appears when the external Docker network declared in docker-compose.yml does not yet exist. Create it manually before the first docker compose up: docker network create proxy. Or switch the network to internal in the Compose (remove external: true) so Compose creates it itself.
nextcloud.your-domain.com redirected you too many times — Nextcloud detects the HTTPS request as HTTP because Caddy forwards it in HTTP over the internal network. Add NEXTCLOUD_TRUSTED_PROXIES with the Docker subnet (e.g. 172.16.0.0/12) and OVERWRITEPROTOCOL=https in the .env. Without these variables, Nextcloud does not trust the X-Forwarded-Proto headers transmitted by Caddy and attempts to redirect to HTTPS indefinitely.
Immich cannot connect to database: connection refused — Immich starts before PostgreSQL is ready. Add depends_on with condition: service_healthy on the immich-server service and verify that the database service declares a valid healthcheck (e.g. pg_isready -U immich). Without a healthcheck, Docker Compose considers the service "started" as soon as the container launches, not when it accepts connections.
Paperless-ngx worker exited with error: celery worker unhealthy — The Redis database is not accessible. Check that the Redis service is on the same network as Paperless and that the PAPERLESS_REDIS variable points to the Compose service name (redis://redis:6379), not localhost — in Docker Compose, localhost inside a container points to the container itself, not to another service.
Caddy does not renew the wildcard certificate — If you use the DNS-01 challenge, verify that the DNS API environment variables (token, zone ID) are properly passed to the Caddy service in Compose. An expired token or insufficient DNS permission causes the renewal to fail silently 30 days before expiration — Caddy logs the error but does not block traffic until actual expiration.
Going further
This stack covers the five most requested services in 2026 homelab configurations. Each has a dedicated guide on this blog if you want to go deeper on a specific aspect: Nextcloud backup, Immich album management, Jellyfin hardware transcoding, Paperless-ngx retention rules or Vaultwarden multi-device sync.
Beyond this stack, the next components commonly added are Uptime Kuma (internal service monitoring) and Ntfy or Apprise (push notifications). These services are lightweight enough to be added to the same Compose without impacting sizing.
If manual update and backup management becomes a burden, Coolify and Dokploy offer interfaces that automate these tasks while preserving the underlying Docker Compose architecture — see the Heroku/Vercel to VPS migration guide for context.