Skip to content

18 — OpenProject (Project Management)

A self-hosted, FOSS project-management tool that teaches the same WBS + Gantt + baseline + EVM workflow as Microsoft Project and Primavera P6 — the two tools utility, power, and EPC employers actually list in job postings — while also covering the agile / Scrum side that PMP-Agile and software-industry Jira shops expect. Used here as a learning platform for the PMP certification path and as a portfolio artifact for project work.

Companion to 17-caddy.md (the HTTPS front-end), 05-backups.md (Postgres dumped nightly to backups/ + the assets/config trees rsync'd to /mnt/backup and the off-site T5), and 13-homepage.md (launcher tile).


Overview

Multi-container Docker stack — the heaviest single service on the box. Cloned from the upstream opf/openproject-deploy repo (stable/17 branch) verbatim; the only customizations live in .env.

Container Image Purpose
openproject-web-1 openproject/openproject:17-slim Rails web app (Puma, 4–16 threads) — UI + REST API + Hocuspocus client
openproject-worker-1 openproject/openproject:17-slim Background job worker (mail, exports, search index, notifications)
openproject-cron-1 openproject/openproject:17-slim Scheduled-jobs runner (daily/weekly maintenance, IMAP poll if enabled)
openproject-seeder-1 openproject/openproject:17-slim Runs db:migrate + db:seed on every up, then exits (restart: on-failure)
openproject-db-1 postgres:17 Primary database
openproject-cache-1 memcached Rails cache store (OPENPROJECT_RAILS__CACHE__STORE=memcache)
openproject-proxy-1 openproject/proxy (built locally from upstream ./proxy/) Internal nginx/Caddy that handles X-Accel-Redirect-style attachment streaming and publishes the stack on 127.0.0.1:8082
openproject-hocuspocus-1 openproject/hocuspocus:17.4.0 WebSocket server for real-time collaborative editing of work-package descriptions
openproject-autoheal-1 willfarrell/autoheal:1.2.0 Restarts the web container if its health_checks/default endpoint goes unhealthy. Mounts /var/run/docker.sock RW (see Design decisions).

Reachable at https://openproject.z2mini.gabrielgabrie.com from any tailnet device. The published 127.0.0.1:8082 endpoint is the internal proxy; nothing reaches it except Caddy on the host. Not exposed to the public internet.

Memory: ~1.5–2 GB at idle (Rails + Postgres + memcached + worker + cron + seeder one-shot + hocuspocus + autoheal + proxy). Plenty of headroom on the 64 GB box.


Design decisions

Decision Reasoning
OpenProject over the lighter Trello-clones (Planka, Vikunja, Kanboard) The lighter tools teach Trello mechanics only. OpenProject's WBS + Gantt + baselines + EVM are the same mental model as MS Project and Primavera P6 — the actual PMBOK / PMP toolkit, and what utility / power / EPC job postings list. Agile boards + sprints are also in the same tool, so the Jira-Scrum muscle memory transfers too. The 5–10× RAM cost vs. Planka is the price of teaching the right thing.
Upstream opf/openproject-deploy repo cloned verbatim (no compose edits) Per the "use upstream config verbatim" rule — the upstream compose has a locally-built proxy/ build context and a fast-moving service graph; hand-typing a stripped-down version is the failure mode. All customizations are in .env (port, hostname, secrets, bind-mount paths). git pull on the stable/17 branch is the upgrade path.
stable/17 branch + TAG=17-slim The current released line as of install (May 2026). -slim ships without the bundled internal nginx (handled by the separate proxy container) and without pdftools/LDAP libs — smaller image, faster pull. Upgrades to stable/18 etc. are deliberate: git pull && docker compose up -d --build --pull always.
Postgres 17 (the upstream .env.example default for new installs) The upstream compose still defaults POSTGRES_VERSION to 13 in its YAML to avoid breaking existing setups; new installs (us) override to 17 via .env. Avoids a future migration.
Named volumes overridden to bind mounts via PGDATA / OPDATA env vars The default named volumes (pgdata, opdata) end up under /var/lib/docker/volumes/ (OS drive) and are awkward to back up. Setting PGDATA=/data/docker/openproject/postgres and OPDATA=/data/docker/openproject/assets in .env swaps them for bind mounts under /data/docker/openproject/ — same pattern as Immich's library/ and postgres/. assets/ stays gabriel-owned (uid 1000 — the container runs as 1000 too); postgres/ is mode 700 uid 999 (Postgres container's user).
Port 8082 (bound to 127.0.0.1 only), fronted by Caddy Free in the reserved-ports table; sits between Vaultwarden (8080) and Beszel (8090). The published port is on the internal proxy container, which proxies to web:8080 over the frontend Docker network. Nothing reaches the container except Caddy, which terminates TLS at https://openproject.z2mini.gabrielgabrie.com.
OPENPROJECT_HTTPS=true + OPENPROJECT_HOST__NAME=openproject.z2mini.gabrielgabrie.com Tells the Rails app to emit https://… URLs in mail, exports, and link previews — required when a TLS-terminating proxy (Caddy) is in front. The HOST__NAME is also the Rails HostAuthorization allow-list — direct requests to 127.0.0.1:8082 with the wrong Host: header get a 400. That's the gate working; smoke-test from the box uses curl -H "Host: openproject.z2mini.gabrielgabrie.com" -H "X-Forwarded-Proto: https" http://127.0.0.1:8082/.
WebSocket (/hocuspocus) via Caddy's auto-WebSocket upgrade Caddy's reverse_proxy upgrades WebSocket connections automatically — no extra config in the site block. COLLABORATIVE_SERVER_URL=wss://openproject.z2mini.gabrielgabrie.com/hocuspocus in .env tells the Rails app to point browsers at the WSS endpoint (same hostname, same cert, port 443). Same pattern Immich and Vaultwarden use for their WebSockets.
autoheal kept (upstream verbatim) — Docker socket mounted RW The trade-off: a compromised autoheal container is root-on-host via the socket. Accepted because (a) the box is single-user, tailnet-only, no public inbound; (b) OpenProject is the heaviest Rails app on the box and the most likely to go unhealthy under memory pressure or a slow query — auto-restart of an unhealthy web is genuinely useful; © keeping the upstream compose unedited is the documented stance. Reconsider if the socket-RW posture ever conflicts with something else.
SECRET_KEY_BASE, COLLABORATIVE_SERVER_SECRET, POSTGRES_PASSWORD — randomly generated, mode 600 .env All three are load-bearing. Rotating SECRET_KEY_BASE invalidates every session and every 2FA-recovery code (Rails uses it to sign cookies and encrypt the 2FA secret column). POSTGRES_PASSWORD is also embedded in DATABASE_URL in .env — change both lines together.
RAILS_MIN_THREADS=4 / MAX=16 (upstream defaults kept) The 10-core / 20-thread i9-10900K + 64 GB box has more than enough capacity. The defaults are sensible for a single-instance Rails app; tune later only if Puma queue depth shows up in real metrics.
IMAP_ENABLED=false; SMTP unconfigured Single-user setup — no inbound email-to-ticket flow, no need for outbound (no invitations, no notifications-via-email yet). Add later by setting IMAP_* for inbound or EMAIL_DELIVERY_METHOD=smtp + SMTP_* for outbound, with a separate Gmail app password from msmtp's (revocation-independence reasoning, same as Beszel's).
restart: unless-stopped on every service (upstream default) Survives reboots, but docker compose stop actually stops.

What was considered and rejected

  • Planka — the closest visual Trello clone (~150 MB RAM). Trivially small, but teaches only kanban — no WBS, no Gantt, no baselines, no EVM. The portfolio story "I used OpenProject's Gantt + baseline workflow, which is the same model as MS Project / P6" is materially stronger than "I used a Trello clone" for utility-industry job hunting.
  • Vikunja — tasks-first FOSS PM tool with kanban, list, and Gantt views; very lightweight (~50 MB RAM). Closer to a personal task manager than a project tool; no baseline workflow, no EVM. Fine for to-dos, weak for PMBOK practice.
  • Kanboard — rock-solid PHP, tiny, mature, lots of plugins. Same "kanban only, no PMBOK toolkit" problem as Planka.
  • WeKan — Meteor + MongoDB. Heaviest of the Trello clones (~500 MB RAM), introduces MongoDB to the stack (everything else here is Postgres or SQLite). Rejected on stack-discipline grounds.
  • Focalboard — was a great Trello clone; Mattermost killed the personal-server edition in 2024. Only community forks remain. Not a safe pick.
  • Taiga — strong Scrum / Kanban tool, very Jira-like for agile. Doesn't cover the predictive (waterfall / Gantt / baselines) side of PMP. OpenProject does both; Taiga only does the agile half.
  • Redmine — old, mature, ubiquitous in legacy enterprise. Has issues + milestones + a basic Gantt, but no baseline workflow, no real EVM, no agile boards. Feels like a 2010s issue tracker; OpenProject feels like a 2020s PM tool.
  • OpenProject Cloud (paid) — same software, ~$8/user/month. Pointless on a tailnet box where free self-hosting is the explicit posture and the hardware is sunk cost.
  • stable/16 or earlierstable/17 was current at install (May 2026); no reason to start on an older line.
  • Dropping the upstream proxy container and pointing Caddy directly at web:8080 — would simplify the stack by one container, but loses the upstream X-Accel-Redirect-style attachment-streaming optimization and deviates from the "use upstream config verbatim" rule. Trade-off rejected.
  • Built-in OpenProject SAML / OIDC / LDAP auth-slim deliberately doesn't ship the LDAP libs. Single-user setup uses local accounts; bridge to SSO later if multi-user.

Install

Directory layout

/data/docker/openproject/                ← `git clone` target — upstream stable/17 branch
├── .env                                 ← mode 600 — version pin + hostname + 3 random secrets + bind-mount paths
├── docker-compose.yml                   ← upstream verbatim — DO NOT edit; customize via .env
├── docker-compose.control.yml           ← upstream — separate compose for `bundle exec rake openproject:reset` ops
├── proxy/                               ← upstream build context for the internal nginx/Caddy proxy
│   ├── Dockerfile
│   └── ...
├── control/                             ← upstream supplementary scripts
├── README.md / .gitignore / .env.example← upstream — kept for git pull
├── postgres/                            ← bind mount — Postgres data dir (mode 700, uid 999) — NEVER filesystem-rsync
├── assets/                              ← bind mount — uploaded attachments, exports, themes (uid 1000) — rsync-safe
└── backups/                             ← nightly pg_dump output (`openproject-db-YYYY-MM-DD.sql.gz`); last 14 retained

.env

.env is the only file edited — every customization lives here, and the upstream docker-compose.yml is untouched. Mode 600.

# OpenProject deployment env. Mode 600. Do NOT commit. Three secrets below are randomly generated.
# Regenerating SECRET_KEY_BASE invalidates all sessions + 2FA recovery codes.

TAG=17-slim
POSTGRES_VERSION=17

# Hostname / TLS — Caddy terminates HTTPS upstream; OpenProject must emit https:// URLs.
OPENPROJECT_HTTPS=true
OPENPROJECT_HOST__NAME=openproject.z2mini.gabrielgabrie.com
PORT=127.0.0.1:8082
OPENPROJECT_RAILS__RELATIVE__URL__ROOT=

IMAP_ENABLED=false
RAILS_MIN_THREADS=4
RAILS_MAX_THREADS=16

# Bind-mount targets — override the upstream named volumes so backups can reach the data.
PGDATA=/data/docker/openproject/postgres
OPDATA=/data/docker/openproject/assets

# Secrets (random, generated at install time — head /dev/urandom | tr -dc A-Za-z0-9 | head -c 32)
SECRET_KEY_BASE=<64 random alphanumeric chars>
COLLABORATIVE_SERVER_SECRET=<32 random alphanumeric chars>
POSTGRES_PASSWORD=<32 random alphanumeric chars>

# Postgres connection — password must match POSTGRES_PASSWORD above (embedded in the URL).
DATABASE_URL=postgres://postgres:<same as POSTGRES_PASSWORD>@db/openproject?pool=20&encoding=unicode&reconnect=true

# Hocuspocus collaborative-editing WebSocket — fronted by Caddy (wss://, port 443).
COLLABORATIVE_SERVER_URL=wss://openproject.z2mini.gabrielgabrie.com/hocuspocus

First boot

# 1. Clone the upstream repo (verbatim).
git clone --depth=1 --branch=stable/17 https://github.com/opf/openproject-deploy.git /data/docker/openproject

# 2. Bind-mount dirs with correct ownership.
mkdir -p /data/docker/openproject/{postgres,assets,backups}
chmod 700 /data/docker/openproject/postgres
sudo chown -R 999:999 /data/docker/openproject/postgres        # matches Postgres container uid
# assets/ + backups/ stay gabriel-owned (uid 1000), matches the web container's run-as user.

# 3. Generate three random secrets and write .env (mode 600). See the .env block above.
chmod 600 /data/docker/openproject/.env

# 4. Validate compose, then pull + build + boot.
cd /data/docker/openproject
docker compose config --quiet
docker compose up -d --build --pull always

# 5. Wait for web to go healthy (seeder runs db:migrate + db:seed first — ~30 s to a few min).
until docker compose ps --format '{{.Service}}={{.Status}}' | grep -q '^web=Up.*(healthy)'; do
  docker compose ps --format '{{.Service}}={{.Status}}' | grep -E 'web|seeder'
  sleep 10
done

# 6. Smoke-test (use the configured Host header — Rails rejects others with HTTP 400):
curl -sI -H "Host: openproject.z2mini.gabrielgabrie.com" -H "X-Forwarded-Proto: https" http://127.0.0.1:8082/
# expect: HTTP/1.1 302 — Location: https://openproject.z2mini.gabrielgabrie.com/login

Caddy site block

Append to /data/docker/caddy/Caddyfile, then validate + reload via the admin API (never restart Caddy — see 17-caddy.md):

openproject.z2mini.gabrielgabrie.com {
    log
    reverse_proxy 127.0.0.1:8082
}
docker compose -f /data/docker/caddy/docker-compose.yml exec -T caddy caddy validate --config /etc/caddy/Caddyfile
docker compose -f /data/docker/caddy/docker-compose.yml exec -T caddy caddy reload --config /etc/caddy/Caddyfile

# Confirm the new host is loaded (not just in the Caddyfile but actually in the running config):
docker compose -f /data/docker/caddy/docker-compose.yml exec -T caddy \
  wget -qO- http://127.0.0.1:2019/config/apps/http/servers/srv0/routes | tr ',' '\n' | grep openproject

# Wait ~20–60 s for Caddy to obtain the cert via DNS-01 (Cloudflare), then verify Let's Encrypt issued it:
echo | openssl s_client -connect openproject.z2mini.gabrielgabrie.com:443 \
  -servername openproject.z2mini.gabrielgabrie.com 2>/dev/null \
  | openssl x509 -noout -issuer -subject -dates
# expect: issuer=C=US, O=Let's Encrypt, CN=...

Gotcha: running caddy reload once doesn't always take — the admin API responded but the new site never showed in /config/apps/http/servers/srv0/routes. Re-issuing the same reload command picked it up. If a new site block doesn't appear in the admin API's routes list within a few seconds, reload again.

First login

  1. Open https://openproject.z2mini.gabrielgabrie.com from any tailnet device.
  2. Login with the seeded credentials: username admin, password admin.
  3. OpenProject forces a password change on first login — set a strong one and store it in Vaultwarden.
  4. Set the System Administrator email under Administration → System settings → General. Even with SMTP off it's used for reset tokens and for the from: address if mail is enabled later.
  5. Create your first Project — give it a name + identifier; the identifier becomes the URL slug and is hard to change later.
  6. Start by adding work packages (WBS) and a Gantt view. Set a baseline on the project before you start tracking variance — that's the workflow that transfers directly to MS Project / Primavera.

Using OpenProject for PMP practice

The point of this install is to get reps on the predictive + agile + hybrid PMBOK workflows in a tool whose mental model transfers to MS Project, Primavera P6, and Jira. A starter pattern:

PMBOK concept OpenProject feature What you do
Work Breakdown Structure Hierarchical work packages Project → Work packages → indent children under parents; the indent tree IS the WBS
Gantt chart Work packages → Gantt view Drag bars to set start/finish; right-click → Add predecessor for FS/SS/FF/SF; lag in days/hours
Milestones Work-package type "Milestone" Zero-duration markers; flag major handoffs
Baseline / variance tracking Project → Baselines (Enterprise feature in some editions; check yours) Snapshot the schedule, then track planned vs. actual over time
Critical path Gantt → enable critical-path highlight Same model as MS Project's "Schedule → Network Diagram → Critical Path"
Costs / EVM Work package → cost / spent units Enter labor cost types; build PV / EV / AC by phase
Risk register Work-package type "Risk" (or a custom type) One WP per risk; custom fields for probability × impact
Agile / Scrum Boards → Backlog + Sprint board Drag stories from backlog into a sprint; burndown auto-tracked
Stakeholder mgmt Members + roles per project Project → Members; map RACI by role assignment

The skills transfer story for a job interview:

"I ran my homelab projects in OpenProject — built the WBS, set baselines, tracked work-package variance against schedule, reviewed EVM weekly. The Gantt + baseline workflow is the same model as MS Project and Primavera P6, so I'd ramp on those quickly. I also ran an agile board on a side project to cover the Scrum side of PMP-Agile."

That's a real, defensible PMP-aligned story.


Operations

Start / stop / pull updates

cd /data/docker/openproject
docker compose up -d                                    # start (or recreate after pull)
docker compose stop                                     # stop without removing
docker compose down                                     # stop + remove containers (data preserved on bind mounts)

# Updates: pull the upstream branch (stable/17), then rebuild proxy + pull base images.
git pull origin stable/17                               # may bump TAG default in .env.example
docker compose pull
docker compose up -d --build --pull always              # seeder re-runs db:migrate
docker compose logs -f --tail 30 seeder web

Logs

docker compose logs -f --tail 50 web                    # Rails app
docker compose logs -f --tail 50 worker                 # background jobs
docker compose logs -f --tail 50 seeder                 # only useful right after up -d
docker compose logs -f --tail 50 db                     # Postgres

Connecting from on the server itself

Direct: http://127.0.0.1:8082/ with Host: openproject.z2mini.gabrielgabrie.com and X-Forwarded-Proto: https headers. Without the right Host: header, Rails rejects with HTTP 400 (the HostAuthorization allow-list).

curl -sI -H "Host: openproject.z2mini.gabrielgabrie.com" -H "X-Forwarded-Proto: https" http://127.0.0.1:8082/
# HTTP/1.1 302 — Location: https://openproject.z2mini.gabrielgabrie.com/login

Via Caddy (works from on-box too): https://openproject.z2mini.gabrielgabrie.com — resolves to 100.67.235.68 (the box's tailnet IP), hits Caddy, no Host: gymnastics needed.

Rails console (rare; for emergency admin)

docker compose exec web bundle exec rails console

Useful for things like resetting the admin password if you forget it (User.find_by(login: 'admin').update!(password: 'NewPassword!1', password_confirmation: 'NewPassword!1')). Be careful — there's no undo.


Backup considerations

In the nightly backup since install. Three pieces:

  1. Postgres database — captured via pg_dump from inside the db container, written to /data/docker/openproject/backups/openproject-db-YYYY-MM-DD.sql.gz (gzipped). Last 14 retained, same retention as Immich's auto-backup. The backups/ directory is then picked up by the per-service rsync (same pattern Immich uses with its library/backups/). Never raw-rsync the postgres/ data dir — a live Postgres data dir snapshot is corrupt.
  2. Uploaded attachments + exports (assets/) — files-as-truth, owned 1000:1000, rsync-safe. Goes into service-config/openproject/assets/.
  3. Config + secrets.env (mode 600 — three load-bearing secrets), the upstream compose + proxy build context, the upstream README/control/etc. files. Small, rsync-safe.

What's not backed up:

  • postgres/ — covered by the .sql.gz dump instead (see above).
  • Docker named volumes — there aren't any; both pgdata and opdata are overridden to bind mounts under /data/docker/openproject/, so everything is under /data (the data drive) and reachable by the backup user.

On the off-site T5service-config/openproject/ is included (the backup-offsite.sh script copies the whole service-config/ tree). That covers the database dump, the assets, and the config — everything needed to restore.

Restore (per 08-recovery.md Step 6b conceptually):

# 1. Re-clone upstream + restore .env + bind-mount dirs:
git clone --depth=1 --branch=stable/17 https://github.com/opf/openproject-deploy.git /data/docker/openproject
rsync -a /mnt/backup/current/service-config/openproject/ /data/docker/openproject/
chmod 600 /data/docker/openproject/.env
sudo chown -R 999:999 /data/docker/openproject/postgres
chmod 700 /data/docker/openproject/postgres

# 2. Boot DB only, then load the dump:
docker compose up -d db
sleep 10
gunzip -c /data/docker/openproject/backups/openproject-db-<DATE>.sql.gz \
  | docker compose exec -T db psql -U postgres -d openproject

# 3. Boot the rest.
docker compose up -d
docker compose logs -f --tail 50 web        # wait for "Listening on tcp://0.0.0.0:8080"

Troubleshooting

HTTP 400 Bad Request directly to 127.0.0.1:8082:

  • Rails HostAuthorization middleware rejects any Host: header not in the allow-list. 127.0.0.1:8082 isn't on the list — the only allowed host is OPENPROJECT_HOST__NAME (openproject.z2mini.gabrielgabrie.com).
  • Smoke-test with the right header: curl -H "Host: openproject.z2mini.gabrielgabrie.com" -H "X-Forwarded-Proto: https" http://127.0.0.1:8082/. Or just go through Caddy (curl -kI https://openproject.z2mini.gabrielgabrie.com/) — Caddy passes the original Host: through unchanged.

Web container stuck at "health: starting":

  • The seeder may still be running db:migrate or db:seed. Check: docker compose logs --tail 30 seeder. First-boot seeding takes 30 s to a few minutes; major-version upgrades (e.g. stable/16stable/17) longer. Web only goes healthy after the seeder exits.
  • If seeder exits with a non-zero code, check Postgres logs: docker compose logs db | tail -50. Common cause: stale postgres/ dir from a different Postgres major version — wipe and reseed (docker compose down, sudo rm -rf /data/docker/openproject/postgres/*, docker compose up -d).

Caddy returns 502 / 504:

  • Web isn't healthy or isn't listening. Confirm: docker compose ps web → "Up X (healthy)" and ss -tln | grep 8082127.0.0.1:8082. The internal proxy container is what binds 8082; if proxy is down but web is up, you'll still get 502.
  • Check Caddy's log for the upstream dial error: docker compose -f /data/docker/caddy/docker-compose.yml logs --tail 30 caddy | grep -i upstream.

Browser shows "Connection refused" / "DNS rebinding" / can't resolve *.z2mini.gabrielgabrie.com:

  • Same DNS story as every other service here — see 17-caddy.md → Troubleshooting. Tailscale must be connected; Tailscale's global resolvers (1.1.1.1 / 8.8.8.8) must be on with "Override local DNS".

Hocuspocus WebSocket fails / collaborative editing doesn't connect:

  • Browser DevTools → Network → WS — look for a failed /hocuspocus connection. The WSS URL is wss://openproject.z2mini.gabrielgabrie.com/hocuspocus; Caddy auto-upgrades to WebSocket, no extra config needed.
  • Common cause: COLLABORATIVE_SERVER_URL in .env is wrong (http:// instead of wss://, wrong hostname). Re-check .env, then docker compose up -d web hocuspocus.
  • Less commonly: COLLABORATIVE_SERVER_SECRET mismatch between .env and a stale hocuspocus container — docker compose restart hocuspocus.

docker compose up -d re-runs the seeder every time and it takes 30+ s:

  • Expected — the seeder runs db:migrate on every up. It's a no-op when there are no pending migrations (just a few seconds of Rails boot + version check). The restart: on-failure policy means it doesn't loop unless it actually fails.

Forgot the admin password:

docker compose exec web bundle exec rails runner \
  "u = User.find_by(login: 'admin'); u.password = 'NewPassword!1'; u.password_confirmation = 'NewPassword!1'; u.save!"

Then log in with admin / NewPassword!1 and change it via the UI.

Autoheal restarted web unexpectedly:

  • Check web's logs around the autoheal event for the underlying unhealthy cause: docker compose logs --tail 200 web. Look for OOM kills (Killed), database connection-pool exhaustion, or Hocuspocus link failures. Autoheal restarting is the symptom, not the bug.

See also