← all posts

Working with contractors without handing over the codebase

How I designed a shared development server where developers get a full private copy of the code, run tests and open pull requests themselves, and still never reach production, take the source home, or touch real data.

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:

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.

DevelopersSSH + editorQAbrowser onlyTech leadownerdev server, private network onlyTicket workspacesone private copy per ticketOffline test sandboxthrowaway user, no networkQA previewsone live link per ticketDev API + databasesanitized data, dev modedeveloper accounts: no internetPR onlyCode hostprotected mainowner deploysProductionseparate serverno path
fig. 1 / everything developers touch lives on one private server. code leaves it only as a pull request; production is not reachable from 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:

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.

refusedDev folderdeveloper-ownedPackedby their accountStageroot-only bytesPolicy checkfour tiersWorktreebase + changesStops herenothing runs
fig. 2 / how a developer's work is read. root handles only bytes; the developer's folder is only ever written by their own account.

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:

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.

Change setbase + this ticket onlyPackage restorenetwork, locked inputsTransient sandboxdynamic user, lives for one stepBuild + testspackage cache read-onlyno networkread-only systemprivate tmp and devices4 GB · 2 CPUs · 20 min a stepNetworkOther ticketsServer data
fig. 3 / tests run as a user that exists for one step, with nothing to reach. the one networked step reads only files nobody can change.

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.

differsTest passeshash of filesSubmitre-read, re-hashSame hash?Pull requesttask branch onlyOwner mergesprotected mainRefusedtest again
fig. 4 / a pull request opens only for the exact set of files that passed. change one byte and it is a new set.

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:

unhealthyMerge main into the server branchrefuse on conflict, change nothingRefresh every ticket workspacetickets under test are skippedRebuild the API only if it changedinto a new folder; old one keeps runningBack up the dev databasenewest five keptSwap build, migrate, restartHealth checkPrevious build backdatabase restore step printed
fig. 5 / syncing the dev server to main. every step can refuse; a bad build or migration rolls back.

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:

  1. Write the requirements as lines that can’t be crossed, and name the mechanism for each one.
  2. Pair every threat with a mitigation, and write down what you are deliberately not defending against.
  3. Give people the whole codebase and gate what gets accepted. Hiding files costs more productivity than it buys in safety.
  4. Treat every step that needs the network as an attack surface, and make its inputs immutable.
  5. 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.