Self-hosting
Run the whole thing inside your own network
If your organisation handles documents that may not be uploaded to anyone — case files, patient records, statements, anything under a data-residency rule — the usual answer is that staff must not use online file tools at all, and they use them anyway. This is the other answer: the same tools, running on your hardware, on a container that needs no account and no network. It is MIT licensed, so there is nothing to buy, register or renew.
Files are processed in the browser tab either way. Self-hosting removes the remaining question — whose server sent the page — so the answer to “where did this document go” is a machine you already own.
One command
The image is published to GitHub Container Registry for linux/amd64 and linux/arm64, so there is nothing to clone and no toolchain to install:
docker run --rm -p 8796:8796 ghcr.io/mgbuilderos/opentools:latestThen open http://localhost:8796. That is the entire site — every tool, every guide — served by the same runtime the public site uses, from an image of roughly 692 MB (measured 2026-09-18; it grows as pages are added, so re-measure rather than trust it). The base image is pinned by digest, the container runs as an unprivileged user, and Wrangler’s metrics, remote lookups and observability are switched off in the image, so nothing contacts Cloudflare.
Pinning it, if your policy requires it
latest follows the most recent release tag, which means it moves. Where approval is granted against a specific artefact rather than a moving tag, take the digest and deploy that instead — the published image carries build provenance attestations, so the digest you approve is the one you can later show you ran:
docker image inspect ghcr.io/mgbuilderos/opentools:latest --format '{{index .RepoDigests 0}}'Or build it yourself
None of the above is required. If your policy is that you compile what you run, or you need something newer than the last release, clone the repository and build the same image from its root:
docker build -t opentools-selfhost:local .
docker run --rm -p 8796:8796 opentools-selfhost:localThe container never receives the document
This is the part worth reading twice, because it is not what self-hosting usually buys you. The common pattern is to move the processing onto a machine you own: the file still leaves the workstation, and it arrives somewhere — request bodies, working memory, temporary files, whatever the process writes while it works, and whatever your backups then copy. That server is inside your perimeter, which answers the residency question, and it is also a system holding client documents, which means it inherits the hardening, the retention schedule, the log review and the access control that go with one.
Here the container serves the page and the page does the work. The tool runs in the browser tab on the machine where the file already is, so the document is never put on the wire and the container never has it to store, cache, log or back up. What you are deploying is a static site, not a document processor.
For whoever signs this off, the consequence is scope rather than comfort: there is no file store to encrypt, no retention period to set, no per-document audit trail to design, and nothing on the instance for a subject access request or a breach notification to reach. The questions that remain are the ordinary ones you would ask of any internal web server — who can reach it, who can change the image, and how it is patched.
Where that argument stops. Serving no documents is not the same as being irrelevant to the outcome: the container still serves the code that runs in the tab, so whoever can change the image can change what the tool does. That is the trust you are actually taking on, it is the same trust you take on with any internally hosted application, and it is why the image is built from a repository you can read rather than pulled from us. Verify the claim rather than accept it — put a synthetic file through a tool with the network tab open, or run e2e/egress-proof.spec.ts against the build you intend to deploy.
It runs with no network at all
You do not have to take that on trust, and it is the fastest way to settle the question for a reviewer. Start the container with networking removed entirely and probe it from inside itself:
docker run --rm -d --network none --name opentools ghcr.io/mgbuilderos/opentools:latest
docker exec opentools node -e "fetch('http://127.0.0.1:8796/').then(r=>console.log(r.status))"| Path | Status | connect-src served |
|---|---|---|
/ | 200 | 'none' |
/robots.txt | 200 | 'none' |
/pdf/sign | 200 | 'none' |
/pdf/compress | 200 | 'none' |
/guides/pdf-sign-pdf | 200 | 'none' |
/sitemap.xml | 200 | 'none' |
/image/background-remover | 200 | 'self' |
/image/background-remover is the single route served connect-src 'self', because it loads its model and WebAssembly runtime from the same origin. An outbound request from inside that container fails to resolve, as it should. This establishes that the server needs nothing; what the browser then does with the pages is the subject of the egress protocol, which you can run against your own build.
Restricting who can reach the instance
Off by default. Set both variables and the instance asks for a username and password before serving anything:
docker run --rm -p 8796:8796 \
-e OPENTOOLS_AUTH_USER=ops \
-e OPENTOOLS_AUTH_PASSWORD='choose something long' \
ghcr.io/mgbuilderos/opentools:latestIt is HTTP Basic, compared in constant time, failing closed if only one of the two variables is set, and checked before anything else runs — so a refused request is never written to the visit log either. It answers one question, “is this person allowed to reach this instance at all”, and deliberately nothing more: accounts would mean storing people, which this product does not do. The public site never has it on.
Settings
PORTdefault:8796Port inside the container.
OPENTOOLS_STATE_DIRdefault:/tmp/opentools-stateWhere the page cache is persisted. It is inside the container, so the cache starts empty after a restart unless you mount a volume there.
WRANGLER_LOG_LEVELdefault:infoLog verbosity.
OPENTOOLS_AUTH_USERdefault:unsetUsername for the optional access gate. Both variables must be set or the gate stays off.
OPENTOOLS_AUTH_PASSWORDdefault:unsetPassword for the optional access gate.
What this does not include
Stated here rather than discovered during your rollout:
- No TLS. Terminate it at a reverse proxy in front of the container — the access gate is HTTP Basic, and Basic credentials over plain HTTP are readable in transit.
- No SSO, no user accounts, no per-user audit trail. The gate is one shared credential for the whole instance, because anything more would mean storing people.
- One container, one process. No clustering, and no shared cache between replicas.
- No published image by default. The image builds on every pull request without being pushed; only a v* tag publishes to GitHub Container Registry.
Before you sign it off
The full build, run and verification steps, including the settings table and the offline measurement above, are in docs/SELF_HOSTING.md, and the image is defined by the Dockerfile. The threat model, the controls and what is out of scope are on the security page, written for the same reader as this one. Everything either page claims can be re-run by you against the build you intend to deploy, which is the point of publishing both.