Self-Hosting Twenty CRM and OpenProject with Two-Way O365 Email Sync
Over the course of a few days I stood up two self-hosted portals for the same underlying reason: SaaS pricing for small teams stops making sense once you actually count seats. Twenty as a CRM, OpenProject for project tracking, both behind nginx on the same box, both wired into Office 365 so email doesn't live in a separate silo. Here's what that build actually looked like, including the part where I locked myself out of login for an afternoon.
Why bother self-hosting either of these
Twenty and OpenProject both have hosted tiers, and both are fine products in that form. The reason to run them yourself isn't ideology, it's arithmetic — once you're past a handful of seats, a VM that already exists and already has spare capacity is cheaper than any per-seat plan, and you're not handing customer or project data to a third party you have to vet. The tradeoff is that you own the ops work: reverse proxy, TLS, OAuth, and the part everyone underestimates, keeping email in sync in both directions.
The stack: nginx in front, O365 OAuth behind
Both apps sit behind an nginx reverse proxy on the same server, each on its own subdomain with its own TLS cert. The more involved piece was Office 365 email integration — not just sending notification emails out, but a genuine two-way sync so replies to a CRM email or a project comment land back in the right place. That means OAuth app registration in Azure, the right Graph API scopes, and application-level config that maps mailboxes to the right accounts.
The instinct with a deployment like this is to declare victory once the service starts and a login page loads. I didn't let myself do that here. "Done" meant sending a test email from each system and confirming the reply showed up where it should — in both directions, for both apps. That extra half hour caught issues neither startup log would have shown.
The gotcha: an empty env value can crash a boolean validator
Configuring the O365 passthrough meant uncommenting a block of environment variables in the YAML config. Several of those variables didn't apply to my setup, so I left them uncommented but empty, assuming an empty string would just be treated as unset. It wasn't — one of those variables fed a boolean validator on startup, and an empty string isn't a valid boolean, so the service crashed on boot. The fix was a full revert of that block and a more deliberate pass: only uncomment a variable if you're also filling it with a valid value, otherwise leave it commented out entirely.
It's a small thing, but it's exactly the kind of failure that costs more time than it should, because the error at boot doesn't obviously point back to "that one empty line you left in." Worth checking a config's validation logic — or at least its default values — before assuming an empty value is harmless.
The bigger gotcha: a duplicate admin record that locked everyone out
The more expensive lesson came from OpenProject, and it started with something that looked completely routine: updating an admin user's email address. That edit silently left behind a duplicate SystemUser record tied to the old address. Nothing broke immediately, which is what made it dangerous — the duplicate sat there for weeks until it surfaced as a total login failure, with password resets broken too, because the app couldn't resolve which record was authoritative.
Tracing it back took real digging: reading through the auth flow, checking the user table for anything that looked like a collision, and eventually finding the duplicate SystemUser sitting there from the earlier edit. The fix itself was straightforward once found. The lesson is the part worth keeping: any edit to an admin or user email should be preceded by a uniqueness check against existing records, not followed by an incident response weeks later. If I'd had a database connection Claude could query directly at the time, I'd have caught the duplicate in the same session as the edit instead of debugging a mystery lockout later.
What I'd change next time
Two portals in, the deployment steps were similar enough that I should have written them down as a checklist after the first one: provision, reverse proxy, OAuth app registration, env config with a validation pass, then explicit two-way email tests before calling it done. I didn't, and rediscovered a chunk of it on the second pass. That's now on my list as a reusable runbook rather than something I reconstruct from memory each time.
The other change is process, not tooling: before any mutation to a user or admin record, check for duplicates first. It's a five-second query against an afternoon of debugging.
Was it worth it
Yes. Both portals have been running cleanly since, email flows both ways, and the combined cost is a fraction of what hosted seats for a team this size would run. The config crash and the duplicate-record lockout were both self-inflicted and both avoidable — which is really the honest takeaway of self-hosting anything: the software mostly works, the ops discipline around it is where you either save the time or lose it.