I spent most of yesterday evening getting a CRM working. Not vibing; just getting my self-hosted instance of Twenty CRM working as a multi-workspace setup to support the needs of a project I’m working on called Community of Practice. It’s a new peer learning initiative for MSPs and IT professionals focused on improving how we solve real-world technical and operational challenges together. No webinars or vendor pitches - and plenty of reasons to put my Proxmox cluster to work as a sandbox for hands-on learning experiences.
This is something I’ve wanted to do since 2023. I drafted an earlier iteration in 2025, and with the help of a couple of IT management vendors I regularly work with, I now have the opportunity to realize the idea before Labor Day gets here. The team at Blumira lit a fire under me after learning about what I had in mind.
Here’s the detail that’s a bit unique and fits in with my goal nicely; I’m not leaning on the vendors for much because I don’t want the project to sit on their infrastructure. Instead, I’m extending my network out, providing access to dedicated spaces in my documentation and version control systems, a dedicated workspace in my CRM, and a few other services.
Much of the core group are people I have known for years, and I’m comfortable providing access - especially when access is properly controlled and requires common best practices, i.e., MFA, etc. I’m excited, I went off on a tangent, and now let’s get back to yesterday evening - I spent it battling CORS.
Everything Works - Except in the Browser
After some configuration changes, Twenty itself was mostly operational. There were two workspaces, the containers were running without any issues. My wildcard DNS entries, NPM config, and TLS certificates were all set - and then Chrome reminded me that none of it matters if your HTTP headers aren’t having a good day.
There are already multiple issues open around CORS behavior in Twenty, so it’s not like I discovered some previously unknown vulnerability or architectural flaw. I just happened to need the exact functionality that currently exposes the problem - and I needed it to work, so I began digging.
Reverse Proxy: Stand Back, I Got This
Me: I’m not getting any sleep tonight, am I…
After taking a good look at what was going on with Twenty, and some time reviewing the issues listed in its repo, I stopped trying to make the application produce the exact headers I required. Instead, I moved the issue up a layer. That’s one of my favorite things about infrastructure - you don’t necessarily have to fix the thing that’s making the mess.
Sometimes you can put the mess behind a sufficiently competent adult - and in this case that adult is Nginx Proxy Manager. First, I stripped the CORS headers coming from Twenty:
|
|
With them out of the way, I needed to tell the proxy which origins I actually trusted. I’m using an internal domain with nested workspace applications, so I built the policy around that namespace:
|
|
I didn’t solve this by throwing * at Access-Control-Allow-Origin because I couldn’t - and even if I could, I prefer taking the scenic route on something like this.
Twenty is making credentialed cross-origin requests, and when credentials are involved, the browser requires an explicit origin. A wildcard simply isn’t a valid answer. As a result, the proxy needs to dynamically return the requesting origin - but only when that origin belongs to the domain structure I actually control.
Next, I added the headers the application needs:
|
|
That last part is important because the response varies depending on the requesting origin.
At this point, I ran into another wrinkle. The application was requesting headers including things like x-locale and x-app-version. Rather than maintaining a list and discovering a new required header every time the application changes, I’m allowing the proxy to reflect the headers requested during preflight.
Then there was the small matter of preflight itself because the browser would like to have a conversation with you before it allows you to have a conversation with your own application.
|
|
That did it. Self-hosted multi-workspace Twenty CRM - working.
Putting it All Together - With Comments!
For anyone else who finds themselves standing in the same CORS swamp, here’s the complete Nginx Proxy Manager configuration I ended up with:
|
|
This is a workaround, not a recommendation for how Twenty should implement CORS. There are already open reports from other users around CORS behavior, and if the underlying issue gets fixed upstream, this configuration should be revisited. I’m more than happy with how this works - for now.
The Hidden Security Boundary
There’s another reason I’m comfortable with the origin-matching approach. Look closely at the domain in that regular expression - I’m deliberately trusting origins underneath a namespace that I control. Like I mentioned earlier, I control the DNS zone and this is my LAN - but the more important question for this particular CORS policy is: who controls those origins I’m allowing?
I control the DNS namespace, the records, and the infrastructure serving up the records. I’m just sitting here like Simba, ruler of all the DNS records I see to the horizon. Call me Samba. I control the reverse proxy, the private network the services live on, and access to that infrastructure is separately controlled.
I’ve written about the DNS side of this in my DNS series, because DNS is one of those pieces of infrastructure that quietly becomes foundational to everything else you build.
The CORS policy is therefore not saying:
“I trust everything on my network.”
It’s saying:
“I trust applications that exist inside a namespace I control.”
That’s a much more meaningful security boundary. Private doesn’t automatically mean trusted - control does. Of course, this is still defense in depth. Authentication and application authorization don’t disappear just because I own the DNS.
The Other Half of This
The CRM is only one part of the infrastructure I’m building for Community of Practice. I want the core group to have access to a useful private environment without requiring me to hand everyone a pile of individual credentials or expose every service to the public Internet and that’s where Twingate comes in.
I’m using it as the access layer for private services. It’s not a marketing decision, and it’s not because I needed another vendor logo attached to the project. I’ve been using Twingate for well over a year, since my first project with them, I’ve had the opportunity to sit with a good portion of the team, and I trust it. Most importantly, it solves a real problem: giving a small group of people controlled access to services running on infrastructure I manage - and depend on.
The fact that Twingate is also part of Community of Practice is a nice bonus and one of the things I like about this project. If something works, we’ll use it. If it doesn’t, we’ll say so - and if people find something useful, they’ll know where they found it. I’d much rather have someone use a piece of technology inside a real environment and form an opinion about it than watch another product demo. That’s a much better test.
Ticket Closed.
What I expected to be a relatively quick task turned into much more yesterday evening. I wanted to extend some infrastructure for a community project, that infrastructure needed a CRM and the CRM had a CORS problem. The CORS issue required an NGINX configuration that took a bit of time to get together - and now it works.
That’s self-hosting in a nutshell; you don’t eliminate complexity, you just get to choose which layer creates a detour in the middle of your evening. Last night it was CORS. Today it’ll be sleep, and by tomorrow it’ll probably be DNS.
At least DNS has the decency to fail in a way I’ve understood since before I could drive.