A self-hosted personal cloud, built on Docker Compose. One docker-compose.yml, one .env file,
your own infrastructure.
| Service | Purpose |
|---|---|
| OpenCloud | File sync and storage (Nextcloud-style) 🗂️ |
| Immich | Photo and video management 🎞️ |
| Jellyfin | Media streaming (movies, shows, music) 📺 |
| Audiobookshelf | Audiobook and podcast server 📚 |
| Dawarich | Personal location history 🗺️ |
| lldap | Lightweight LDAP identity backend |
| Authelia | OIDC/SSO provider, sits in front of lldap 🪪 |
| Cloudflare Tunnel | Remote access without opening ports 🛜 |
| EuroOffice | In-browser document editing for OpenCloud 📑 |
| MinIO | S3-compatible object storage backing OpenCloud 🗃️ |
| Watchtower | Automatic container image updates 👀 |
Every service sits behind Authelia/lldap for identity and is only reachable through the Cloudflare Tunnel — nothing is exposed directly to the internet.
- Compose structure —
docker-compose.ymlat the root usesinclude:to pull in one compose file per service fromservices/*/. Adding a service means adding a new directory there and including it. - Identity/SSO — lldap is the LDAP backend; Authelia sits in front of it as the OIDC provider for every other service.
- No shared database — each service that needs Postgres runs its own dedicated
<service>_dbcontainer with its own bind-mounted data directory. There's no single shared database instance. - Network exposure — every service's host port is bound to
127.0.0.1only.cloudflaredis the sole public ingress point: it reaches every other service over the shared Docker network by container name, so it doesn't need a host port binding itself. Keep any new service's ports bound to127.0.0.1too — only the tunnel should ever front something publicly. - Data layout — nearly all persistent state is an explicit bind mount under
docker_data/<service>/, which is whycore/backup.sh/core/restore.shcan just rsync the wholedocker_data/tree instead of maintaining a list of Docker volumes. Two exceptions live on the Hetzner Storage Box instead of local disk (hetzner_smb/immich_upload,hetzner_smb/minio_data), and the media library Jellyfin/Audiobookshelf read from is a separate rclone WebDAV mount (data/media_mount) pointed at OpenCloud's "Media" project space. - Watchtower polls for image updates every 2 hours and prunes old images after updating.
- Docker and Docker Compose
rclone— mounts your cloud storage remotecifs-utils— mounts your SMB/CIFS storage box- A domain you control, proxied through Cloudflare
- Storage: a Hetzner Storage Box (or similar SMB-capable remote storage) for backups and media
This repo is one of three sibling directories that must live next to each other:
parent-dir/
├── HomemadePasta/ ← this repo
├── docker_data/ ← all container state (created by setup.sh)
└── hetzner_smb/ ← your SMB/CIFS storage mount (created by setup.sh)
-
Clone this repo into its own directory (see layout above):
git clone https://github.com/GustavOnsberg/HomemadePasta.git cd HomemadePasta -
Run the setup script — copies the env template and creates the sibling data directories:
./setup.sh
-
Edit
config/.env— replace everyplease_change_meplaceholder with a real value, and fill in your domain, email, and cloud storage credentials. The comments inconfig/.env.templateexplain what each variable is for. -
Configure rclone — set up the
opencloud-mediaremote, used to mount OpenCloud's "Media" project space for Jellyfin/Audiobookshelf:rclone config
-
Start the stack:
./manage.sh start
-
Wire up SSO manually — register OIDC clients in Authelia's config (
services/authelia/) for each service, and create an OpenCloud app token for themedia_adminuser (used bycore/media_mount.sh). This isn't automated.
cloudflared is the only public ingress point — every service's host port is bound to
127.0.0.1 only, so nothing is reachable except through the tunnel. The tunnel itself connects
outbound to Cloudflare (no inbound firewall rule needed), and cloudflared reaches every service
over the shared homemadepasta_default Docker network by container name — so the public hostname
config below (set up in the Cloudflare dashboard under your tunnel's Public Hostname tab, not
in this repo) is all that's needed to wire each one up.
Replace yourdomain.com with your actual DOMAIN from config/.env:
| Public hostname | Service | Points to (internal) |
|---|---|---|
auth.yourdomain.com |
Authelia (OIDC/SSO) | http://authelia:9091 |
files.yourdomain.com |
OpenCloud | http://opencloud:9200 |
eurooffice.yourdomain.com |
EuroOffice | http://eurooffice:80 |
photos.yourdomain.com |
Immich | http://immich-server:2283 |
audiobooks.yourdomain.com |
Audiobookshelf | http://audiobookshelf:80 |
tv.yourdomain.com |
Jellyfin | http://jellyfin:8096 |
timeline.yourdomain.com |
Dawarich | http://dawarich-app:3000 |
ldap.yourdomain.com |
lldap admin UI | http://lldap:17170 |
The first seven match the *_URL variables in config/.env — keep them in sync if you change a
subdomain. ldap.yourdomain.com is the exception: it's hardcoded as LLDAP_HTTP_URL in
services/lldap/lldap-compose.yml rather than sourced from .env.
./manage.sh start # Mount everything, bring the stack up, prune unused images/volumes
./manage.sh stop # Bring the stack down, unmount everything (no backup)
./manage.sh restart # stop + start
./manage.sh status # Show container and mount status
./manage.sh snapshot_sync # Back up, take a storage box snapshot, then restart the stack
./core/backup.sh # rsync this project + docker_data to the backup destination
./core/restore.sh # Interactively restore from that backup
./core/hetzner-snapshot.sh # Trigger a point-in-time storage box snapshotmanage.sh snapshot_sync is meant to run on a nightly cron job — it backs up, takes a storage box
snapshot, and restarts the stack in one resilient pass (a failure in any one phase doesn't leave
the stack down, it just warns and moves on to the next phase). Example crontab entry, run as
whichever user has permissions on HomemadePasta/docker_data/hetzner_smb:
0 4 * * * cd /path/to/HomemadePasta && ./manage.sh snapshot_sync >> /path/to/HomemadePasta/cron.log 2>&1This stops the stack, backs up, snapshots, and brings it back up once a day — expect a few
minutes of downtime at that hour. cron.log is gitignored; check it if a scheduled run misbehaves.
Personal project, shared as-is. No warranty — read before you run it on anything that matters.