When you bring in developers you can’t yet hand the keys to, the usual options are both bad. Give them a clone of the repository and production credentials “for now”, and you have lost control of the code on day one. Give them a few files at a time, and they can’t build, can’t run, and can’t see what their change does.
At Nibras Legal Tech the team is the tech lead plus developers and QA who are not trusted with production or with a copy of the code on their own machines. I wrote the design for a third option: one on-premises development server that gives every developer a full, private copy of the codebase, live previews for QA, and self-service testing, migrations and pull requests, while production, its data and the main branch stay with the owner.
The design starts from one sentence: developers are assumed to be honest, but they should not need to be.
In short:
- Write the requirements as hard lines first: no path to production, source stays on the company’s machine, dev data is never production data.
- Give each ticket a full private copy of the repository, watermarked so a leaked copy can be traced.
- Decide what changes are accepted when work is read, not by hiding files from the editor.
- Run every test in an offline, throwaway sandbox, and lock the files that could steer the one step that needs the network.
- Merge only what was tested: the pull request is gated on a hash of exactly the files that passed.
- Make the environment sync back itself up and roll itself back.
Requirements before mechanisms
Before any tooling, I wrote down what had to be true. Each requirement had to name how it would be met, or it was a wish.
| Requirement | How it is met |
|---|---|
| Developers never reach production: no server, database, credentials or deploy | Production is a separate server with no path from the dev server; deploys are manual and owner-only |
| Source code does not leave the company’s machine | Developer accounts have no internet; code lives on the server; copies are watermarked |
| Developers see and run the whole codebase, not fragments | A full private repository copy per ticket |
| QA watch work in progress without the developer’s help | An always-on preview per ticket |
| Developers test, generate migrations and open pull requests themselves | A small set of self-service actions, limited to their own tickets |
| Review, merge and deploy stay with the tech lead | Protected main branch; developers hold no code-host credentials |
| Dev data is never production data | A sanitized dev database, and an API that runs in development mode |
| Low cost | One virtual machine on an office PC; CI runs only the jobs a change needs |
Writing the non-goals down mattered just as much. The design does not try to stop a determined insider from retyping what they can read. Nothing can. It makes leaking deliberate, visible and traceable, and NDAs and watermarks cover the rest. High availability of the dev server is also not a goal: it is a development tool, and production does not depend on it.
Everything developers and QA touch runs on that one server, which is reachable only over a private network. The code host holds the repository and CI. Production is a separate server that the dev server cannot reach at all. Code gets to production one way: a pull request, a merge by the owner, and a deploy the owner starts by hand after CI is green on that exact commit.
Threats, then mitigations
With the requirements fixed, I listed what could go wrong and paired each threat with the mechanism that answers it. A threat without a mitigation is either accepted on purpose or a gap you haven’t admitted yet.
| Threat | Mitigation |
|---|---|
| A developer reaches production | No network path from the dev server; production credentials exist only in the code host’s secrets and the owner’s hands; deploys are manual and never cancelled mid-run |
| Code is pushed or uploaded elsewhere | Developer accounts get no outbound connections; each workspace carries a per-ticket watermark |
| Secrets in the repository | Environment files, keys, certificates, app settings and anything named like a secret or credential are removed from every copy, along with infrastructure, CI workflow, script and Docker files |
| Malicious code in tests | Tests run in a sandbox: throwaway user, no network, read-only system, everything else hidden, hard limits |
| Steering the package restore, the one step with network | Restore reads only build files developers can never change |
| Root acting on developer-controlled files | Root never runs anything inside a developer’s folder |
| Unreviewed code reaching main | Developers hold no code-host credentials; main is protected; the owner merges |
| CI abused by a pull request | CI uses no repository secrets, its token is read-only, and workflow files can’t be changed from a ticket |
| Dev data mistaken for real data | Sanitized database; dev-only switches work only when the API runs in development mode |
Every privileged action passes through code that checks it, and every path to production goes through the owner.
A private copy per ticket
A ticket is the unit of work: one developer, one private copy of the repository, one preview, one pull request.
The copy is a real, writable checkout at the ticket’s base commit, in a folder the developer owns. They can read everything, create files, run the frontend, and use their own git. The workspace contains:
- The repository at the base commit, minus the never-granted paths.
- A private git history with that base committed, so
git statusandgit diffshow exactly their changes. - Its own frontend dependencies, so the frontend and its checks run without the internet.
- A short task file: the goal, what they may change, their commands, and the QA link.
- A watermark: a comment line carrying a per-ticket token, placed in a few random source files. It is stripped whenever their work is read, so it never reaches the repository. If a copy turns up somewhere it shouldn’t, the token says which workspace it came from.
There are two kinds of ticket. An open ticket, the default for the team, lets the developer change anything in their copy except locked build files, including sensitive areas such as authorization and migrations. Review happens on the pull request. A scoped ticket allows changes only to listed files or folders, which suits a newcomer or a narrow fix.
QA get a preview for every ticket on a fixed link that never changes, with a banner naming the ticket and the developer. It updates every time the developer saves. The preview runs the developer’s code as the developer, so it has nothing they don’t already have.
Four tiers of file policy
What a developer may change is decided by four tiers, each answering a different risk:
| Tier | Meaning | Examples |
|---|---|---|
| Never granted | Not in any copy at all | Infrastructure, CI workflows, secrets |
| Locked | Visible, but never changeable from a ticket, even an open one | Project files, package manager configuration |
| Sensitive | Read-only on scoped tickets unless explicitly granted; free on open tickets | Authorization, middleware, migrations, frontend package manifest |
| Context | Added read-only next to an area being edited | Shared UI kit, API contracts |
The important design choice is when the policy applies. The first version tried to control what developers could see. The final version gives them the whole repository and controls what is accepted when their work is read.
Root never runs inside a developer’s folder
Several actions need root: building a worktree, running tests, pushing a branch. The obvious way to do that is for root to walk into the developer’s folder and read it. That is also the obvious way to get attacked: anything in that folder, a symbolic link, a special file, a crafted name, is something the developer controls.
So root never runs anything inside a developer’s folder. The developer’s own account packs the work. Root unpacks those bytes into a stage only root can read, refuses links and special files, normalizes line endings, and strips the watermark. Then the policy check runs, and only then is a worktree built from the base commit plus their changes.
A refused change stops everything. Nothing of theirs runs and nothing is committed. Running checks on a change set that contains refused files would let those files steer the tools, so the test does not start at all.
Self-service sits on top of this through a narrow sudo rule. A developer can run a fixed list of actions (test, submit, refresh, migration, read the test log, manage their previews) with no password, and the tool itself checks again: only those actions, one ticket argument, only tickets they own, and no extra options. There is no “skip tests” and no “use this commit” for a developer.
Tests in an offline sandbox
Tests run on a throwaway worktree, the base plus only that ticket’s changes, inside a transient systemd unit with a dynamic user. The checks depend on what the change touches:
- Frontend: TypeScript typecheck, ESLint on the changed files, and the Vitest tests related to them.
- API: package restore, build, test project build, and the full API test suite. Tests that need PostgreSQL skip themselves and run in CI.
- Shared packages: both sides.
- Anything else (docs, data): noted in the log as unchecked.
The sandbox has this shape:
# Simplified: the properties of every test step
DynamicUser=yes # a user that exists only for this step
PrivateNetwork=yes # no network, except the restore step
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict # the system is read-only
ProtectHome=yes
NoNewPrivileges=yes
# limits: 4 GB memory, 2 CPUs, 20 minutes per step
# hidden: server data, ticket state, every other workspace,
# SSH and database configuration
The package cache is read-only while their code runs, and builds run without build servers, so nothing a test starts outlives the step.
The one hole is the package restore, which needs the network. Anything that steers a restore is a way to run code with network access: project files, props and targets files, solution files, response files, the SDK version file, the NuGet configuration. So those files are locked. Developers can read them but never change them, and any refused file stops the test before anything runs. That keeps the one networked step inert.
Migrations follow the same path. Developers can’t run the EF Core tools themselves, because the tools’ packages need the internet. Instead a migration is generated for them in the sandbox: restore with locked inputs, build, then add the migration offline. Adding a migration only builds the model through the design-time factory, so no database is contacted. The new files are copied into their folder with their own rights.
The merge gate: exactly the files that passed
The test log records a hash of exactly the files that were tested. Submitting a ticket checks that hash.
Submit re-reads the developer’s folder, runs the policy check again, and confirms the last test passed on exactly these files. Then it commits the changes on the ticket’s base in a throwaway worktree, carries that commit onto the current main as a task branch, pushes it with the server’s own token, and opens or updates the pull request. The body lists the files and the checks that passed.
Developers never hold a code-host credential. They can only push a task branch, and only through submit. CI runs on the pull request; I review and merge or request changes, and they fix, test and submit again to the same pull request. Branch protection applies to me as well.
Submits are rate limited to once every ten minutes per ticket, because every push runs CI and CI minutes are paid.
An environment that keeps itself in sync
When a pull request is merged, one sync brings the server up to main, and every step is built to either succeed or leave things as they were:
Frontend dependencies are reinstalled only when the lockfile changes. If migrations fail or the API does not come back healthy, the previous build is restored automatically, and the exact restore step for the pre-sync database backup is printed.
Refreshing a ticket rebuilds its folder on the new code with the developer’s changes replayed on top. Folders are never edited in place: a new one is built next to the old one and swapped in only if the developer’s work did not change in the meantime, and the previous folder is kept. If their changes overlap with the new code, a plain refresh refuses and leaves the folder untouched. A refresh with merge moves them onto the new code with standard conflict markers labelled “new code (main)” and “your changes”, and test and submit refuse until none remain. Binary files and files deleted on one side are resolved by hand.
Outbound rules by group
Network access is decided by group membership and enforced by a firewall installed at boot:
| Account group | Loopback | Mail catcher and databases | Office network / private network | Internet |
|---|---|---|---|---|
| Developers | allowed | blocked | blocked | blocked |
| Outside work | allowed | blocked | blocked | allowed |
| Service accounts and the tech lead | unrestricted | unrestricted | unrestricted | unrestricted |
Replies always flow. Connections a developer did not open, like their SSH session and the editor’s tunnel, are established connections and allowed both ways; only new outbound connections are checked. Because the server has no internet for them, the editor uploads its own server component from the developer’s laptop.
A developer who also works on another project gets a second, separate account for it. That account has the internet for its own repository and packages, but no Nibras files, services, office network or private network. The Nibras account never gets the internet instead, because opening it for one repository would open a path for Nibras code too.
Design decisions and lessons
Copy workspaces instead of bind-mounted files. The first version mounted a few files into the shared tree. Developers didn’t have the context to build or see their work, and editors broke the mounts. A full private copy works like a normal checkout. Control moved from “what they can see” to “what is accepted”.
Submit instead of accept. Originally I could accept a ticket onto the dev server and open a pull request separately. That let the server’s branch and main drift apart, and a later merge could conflict. With submit, work reaches the server only through main.
Ticket numbers are never reused. A reused number mixed an old closed ticket’s history with a new one. Active, archived and former numbers all count, and a running ticket can be renumbered without disturbing its developer.
Conflict markers instead of refusals. Refusing a conflicting refresh gave developers no way forward. Markers are standard, editors resolve them, and tests refuse until they are gone.
Self-service through a narrow rule. Waiting on the lead for every test and pull request did not scale. A narrow sudo rule plus checks inside the tool gives speed without new trust.
CI change detection. Running every job on every change spent 1,900 of our 2,000 monthly CI minutes in 12 days. A full backend and frontend run costs about 20 billed minutes. Now the first CI job looks at what the change touched, against the pull request base or the previous push, and only the jobs that change needs run. Skipped jobs count as success, so a docs-only change costs almost nothing. A change to the CI workflow itself, or an unknown base, runs everything. A newer push to the same branch cancels the superseded run, and the submit rate limit keeps pushes from piling up.
What I’d tell someone building the same thing:
- Write the requirements as lines that can’t be crossed, and name the mechanism for each one.
- Pair every threat with a mitigation, and write down what you are deliberately not defending against.
- Give people the whole codebase and gate what gets accepted. Hiding files costs more productivity than it buys in safety.
- Treat every step that needs the network as an attack surface, and make its inputs immutable.
- Merge exactly what was tested, not something close to it.
If you are setting up something similar for your own team, I’m happy to compare notes. You can reach me through my site.