Davenport Software — one repo, one command, your domain. Photos, files, office, webmail, passwords, wiki, and notes — self-hosted behind a Cloudflare Tunnel, no open ports.
This is a reusable deployment package. It ships no real secrets: every
password/key/token is generated fresh by deploy.sh into per-service .env
files (chmod 600, git-ignored).
| Service | What it does | Image | Public host (default) | Local port |
|---|---|---|---|---|
| Authentik | SSO / Identity Provider (OIDC) | ghcr.io/goauthentik/server |
auth.<domain> |
9000 |
| Immich | Photo & video library (+ ML) | ghcr.io/immich-app/immich-server |
photos.<domain> |
2283 |
| OpenCloud | File sync & share | opencloudeu/opencloud-rolling |
files.<domain> |
9200 |
| ↳ Collabora CODE | In-browser office editor | collabora/code |
collabora.<domain> |
9980 |
| ↳ WOPI / collaboration | OpenCloud↔Collabora bridge | opencloudeu/opencloud-rolling |
wopi.<domain> |
9300 |
| Rolltop | Webmail client (IMAP/SMTP) | ghcr.io/grahamsz/rolltop (patched) |
mail.<domain> |
8090 |
| Vaultwarden | Password manager (Bitwarden-compatible) | vaultwarden/server |
vault.<domain> |
8089 |
| Outline | Team knowledge base / wiki | outlinewiki/outline |
outline.<domain> |
3300 |
| SilverBullet | Markdown notes | ghcr.io/silverbulletmd/silverbullet |
notes.<domain> |
8091 |
| Homepage | Dashboard / app launcher | ghcr.io/gethomepage/homepage |
home.<domain> |
3010 |
All containers bind to 127.0.0.1 only. The internet reaches them solely
through the Cloudflare Tunnel, which terminates TLS. Nothing listens on a
public interface; no inbound ports are opened on the host firewall.
Internet (HTTPS)
│
Cloudflare edge (TLS)
│ cloudflared tunnel
▼
┌──────────────── host: 127.0.0.1 ────────────────┐
│ │
Authentik (IdP) ◀───OIDC─── Outline │
▲ │
│ (optional OIDC for other apps later) │
│ │
OpenCloud ◀──WOPI──▶ collaboration ◀──▶ Collabora CODE │
│ (built-in IdP; OC_DOMAIN baked into OIDC issuer) │
│ │
Immich Vaultwarden Rolltop SilverBullet Homepage │
└──────────────────────────────────────────────────┘
Hard dependencies:
- Outline → Authentik — Outline has no local password login; it requires the Authentik OIDC provider. Authentik must be up and its provider created before Outline login works.
- OpenCloud → Collabora + WOPI — office editing needs all three of
opencloud,collaboration(WOPI), andcollaboracontainers, plus the three public hostnames (files,collabora,wopi) reachable over HTTPS. - Immich, Outline each run their own Postgres + Redis; Authentik runs its own Postgres. No shared database.
Deploy order (deploy.sh does this automatically):
- Phase A — Authentik (IdP) + its DB.
- Phase B — the standalone apps (Immich, OpenCloud+Collabora, Rolltop, Vaultwarden, SilverBullet, Homepage), each with its own DB/Redis.
- Phase C — OIDC consumers (Outline), once Authentik is reachable.
git clone <this-repo> selfhost-suite && cd selfhost-suite
cp config.env.example config.env
$EDITOR config.env # set BASE_DOMAIN, ADMIN_EMAIL, pick services
./deploy.sh # installs Docker if needed, generates secrets, brings stacks upUseful flags:
| Flag | Effect |
|---|---|
--yes / -y |
Non-interactive (assume "yes" to prompts, e.g. Docker install). |
--render-only |
Generate .env files + the cloudflared config, but don't docker compose up. Great for inspecting first. |
--tunnel |
Also run cloudflared tunnel create + route DNS (requires cloudflared logged in). |
Re-running deploy.sh is idempotent for secrets: an already-rendered
.env is preserved (it will not rotate a DB password and orphan its data).
Delete a service's .env to force-regenerate it.
Copy config.env.example → config.env. Key fields:
BASE_DOMAIN— your apex domain; every service defaults to a subdomain of it.ADMIN_EMAIL— bootstrap admin + SMTP "from" default.ENABLE_<SERVICE>="true"/"false"— pick your subset. Disabled services are skipped entirely and left out of the tunnel config.- Per-service
*_DOMAINoverrides — only if you don't want the default subdomain. - Storage paths (
IMMICH_UPLOAD_LOCATION,OPENCLOUD_DATA_DIR, …) — point big data at a dedicated disk; blank =./dataunder the service dir. SMTP_*— optional; wired into Outline, Vaultwarden, OpenCloud notifications.
No secrets live in
config.env. Everything secret is generated.
Every secret is generated fresh by deploy.sh with openssl rand — each
marker gets its own distinct value, strengthened by format where it matters:
| Secret | Generation |
|---|---|
| DB passwords (Immich, Outline, Authentik, OpenCloud admin, Collabora admin) | openssl rand url-safe, 24 B |
AUTHENTIK_SECRET_KEY |
openssl rand -hex 64 |
AUTHENTIK_BOOTSTRAP_TOKEN |
openssl rand -hex 32 (also saved to services/authentik/.apitoken) |
Outline SECRET_KEY / UTILS_SECRET |
openssl rand -hex 32 |
Vaultwarden ADMIN_TOKEN |
openssl rand -base64 48 |
Rolltop ROLLTOP_MASTER_KEY |
openssl rand -hex 32 |
| SilverBullet basic-auth | <admin-localpart>:<random> |
Rendered .env, out/, and all data dirs are git-ignored. Templates are
committed (no real values). Back up the rendered .env files somewhere
safe — losing e.g. the Vaultwarden ADMIN_TOKEN or Authentik SECRET_KEY is
disruptive; losing ROLLTOP_MASTER_KEY means re-adding all webmail accounts.
deploy.sh always renders out/cloudflared/config.yml containing an ingress
rule per enabled service (hostname → http://127.0.0.1:<port>), ending in
a 404 catch-all. The hostname→port mapping matches the table in §1.
Requires cloudflared installed and cloudflared tunnel login done. The script
will tunnel create "$CF_TUNNEL_NAME", splice the tunnel UUID into the config,
and tunnel route dns each hostname. Then install it:
sudo cp out/cloudflared/config.yml /etc/cloudflared/config.yml
sudo cloudflared service install # or run: cloudflared tunnel run <name>cloudflared tunnel login
cloudflared tunnel create selfhost-suite # note the UUID it prints
# edit out/cloudflared/config.yml: replace __SET_BY_cloudflared_tunnel_create__
# with the UUID (both the `tunnel:` line and the credentials-file path)
sudo cp out/cloudflared/config.yml /etc/cloudflared/config.yml
sudo cp ~/.cloudflared/<UUID>.json /etc/cloudflared/<UUID>.json
for h in auth photos files collabora wopi mail vault outline notes home; do
cloudflared tunnel route dns selfhost-suite "$h.<your-domain>"
done
sudo cloudflared service installEach tunnel route dns creates a proxied CNAME → <UUID>.cfargotunnel.com
in Cloudflare DNS. Confirm the records are proxied (orange cloud).
- Browse to
https://auth.<domain>/. Log in asakadminwithAUTHENTIK_BOOTSTRAP_PASSWORDfromservices/authentik/.env.GOTCHA: the bootstrap password / token / email apply only on the first DB init (empty
pgdata). Changing them in.envafterward does nothing — reset via the Authentik UI orakCLI instead. - Set up your own admin user + (recommended) MFA.
Outline can't log anyone in until this is done.
- In Authentik: Applications → Providers → Create → OAuth2/OpenID Provider.
- Redirect URI:
https://outline.<domain>/auth/oidc.callback - Note the generated Client ID and Client Secret.
- Scopes:
openid profile email.
- Redirect URI:
- Create an Application bound to that provider (slug e.g.
outline). - Put the values in
services/outline/.env:(The auth/token/userinfo URIs are already templated toOIDC_CLIENT_ID=<from authentik> OIDC_CLIENT_SECRET=<from authentik>auth.<domain>.) - Restart Outline:
(cd services/outline && docker compose up -d). - Visit
https://outline.<domain>/→ "Continue with Authentik".
Mostly automatic via the weboffice/collabora.yml + external-proxy overlays.
After up, verify:
https://collabora.<domain>/hosting/discoveryreturns XML (CODE is healthy).- In OpenCloud, opening a
.docx/.odtoffers Collabora as an editor. - Admin console:
https://collabora.<domain>/browser/dist/admin/admin.html(user/pass =COLLABORA_ADMIN_*inservices/opencloud/.env).
GOTCHA:
OC_DOMAINis baked into the OpenCloud OIDC issuer + built-in IdP on first init. Decide the finalfiles.<domain>hostname before the firstup; changing it later breaks login and the WOPI/CSP frame rules.
User admin, password = INITIAL_ADMIN_PASSWORD in services/opencloud/.env
(first-init-only, like Authentik).
- Browse
https://vault.<domain>/, create your account whileSIGNUPS_ALLOWED=true. - Then set
SIGNUPS_ALLOWED=falseinservices/vaultwarden/.envanddocker compose up -dto lock down registration. - Admin panel:
https://vault.<domain>/adminwithADMIN_TOKEN.
Per-user IMAP/SMTP client — users add their own mail accounts at login. No server-side mailboxes. See the patch note in §8.
- SilverBullet: HTTP basic-auth, creds in
services/silverbullet/.env(SB_USER=user:pass). - Homepage: edit
services/homepage/config/services.yamlto add tiles/links to your other services (icons, hrefs). It's a static dashboard.
- Rolltop missing-plugin patch. Upstream
ghcr.io/grahamsz/rolltop:latestships themail_filtersplugin manifest but not the compiled.so, so the loader aborts at startup withrealpath failed.services/rolltop/Dockerfilebuilds a local image that deletes/app/plugins/mail_filtersso the loader skips it. (That's why Rolltop isbuild: ., not a bare image.) - OpenCloud
OC_DOMAINis immutable-ish. Baked into the OIDC issuer + IdP on first init. Pick the hostname before firstup. See §7.3. - Authentik bootstrap is first-init-only.
AUTHENTIK_BOOTSTRAP_PASSWORD/_TOKEN/_EMAILonly apply whenpgdatais empty. After that, manage users/tokens in the UI. The generated token is stashed atservices/authentik/.apitokenfor first-boot API automation. - Collabora needs privileges. The CODE container runs with
SYS_ADMIN+seccomp:unconfined+apparmor:unconfined(its sandbox needs them). Expected. - Everything binds 127.0.0.1. If you front this with something other than Cloudflare Tunnel (e.g. a local reverse proxy), it must reach the loopback ports in §1; do not publish these ports publicly without TLS + auth.
home.<domain>port. Homepage listens on3010(see §1).
selfhost-suite/
├── deploy.sh # master one-shot deployer
├── config.env.example # copy → config.env (domains, toggles, SMTP)
├── lib/common.sh # helpers: secret gen, env render, compose, healthchecks
├── cloudflared/
│ └── config.yml.template # tunnel ingress template (rendered to out/)
└── services/
├── authentik/ { docker-compose.yml, .env.template }
├── immich/ { docker-compose.yml, .env.template }
├── opencloud/ { docker-compose.yml, .env.template,
│ weboffice/collabora.yml, external-proxy/{opencloud,collabora}.yml,
│ config/opencloud/{csp.yaml,banned-password-list.txt} }
├── rolltop/ { docker-compose.yml, Dockerfile, .env.rolltop.template }
├── vaultwarden/ { docker-compose.yml, .env.template }
├── outline/ { docker-compose.yml, .env.template }
├── silverbullet/{ docker-compose.yml, .env.template }
└── homepage/ { docker-compose.yml, config/* }
Rendered .env files, out/, and data directories are git-ignored and never
contain template placeholders after a successful deploy.
# status / logs for one service
(cd services/immich && docker compose ps)
(cd services/outline && docker compose logs -f)
# update a service to the latest pinned tag
(cd services/<svc> && docker compose pull && docker compose up -d)
# stop / start a service
(cd services/<svc> && docker compose down)To add or remove a service later, flip its ENABLE_* in config.env, re-run
./deploy.sh (existing secrets preserved), and re-install the regenerated
out/cloudflared/config.yml.