SSD Nodes Learn 🎉 VPS from $4.99/mo
Guides Matt ConnorBy Matt Connor

Self-host open-kritt AI security scanning

Run open-kritt on a VPS: Docker Compose setup, pinning a release, SSH tunnel to the UI on port 5173, and the provider budget to set before your first scan.

Why self-host open-kritt on a VPS and not your laptop

Self-host open-kritt on a server you can destroy and rebuild. The tool runs its analysis agents as root inside disposable job containers, gives each one a writable copy of your code and direct internet access, and mounts the host Docker socket into its engine service. That is a reasonable trade on a box dedicated to the job. It is a bad one on the machine that holds your SSH keys.

Four properties of the default setup drive that advice, and all four come from the project's own README and compose file.

The agents are meant to be powerful. The README says tool-enabled agents run as root inside disposable job containers, with writable repository copies and direct internet access, so they can install tools, compile targets, run tests, and build proofs of concept. A scan is not a linter reading files. It is arbitrary code execution that you asked for.

The engine holds the Docker socket. docker-compose.yml mounts the host Docker socket into the engine service, because the engine builds and launches one scan container per job. Any process that can reach that socket can start a container that mounts the host filesystem. So the engine is effectively root on whatever host runs it.

There is no login screen. The backend ships with no application authentication. Access to the port is access to your findings and to your provider credit.

The code you scan is often not yours. Pointing agents at a third-party repository means running that repository's build on your machine, as root, with network access.

If you have read why coding agents belong in a disposable VM, this is the same threat model, only stronger. Give open-kritt a VPS with nothing else on it, and drive that VPS from a separate least-privilege user account instead of root.

What open-kritt actually does

open-kritt (the repository is Kritt-ai/open-kritt, licensed AGPL-3.0) breaks vulnerability research into small tasks, runs those tasks across AI agents in parallel, then de-duplicates and ranks what comes back. You define a workflow as a chain of focused prompts, and each step receives structured context from the steps before it. The scan target is a remote or local git repository. The analysis engine is Codex or Claude Code. After a candidate appears, optional post-scripts can try to validate it or build a proof of concept.

What you get at the end is a ranked list of candidates. Treat it as a triage queue, not as a report.

What you need before you start

  • A VPS running Ubuntu 24.04, Debian 12 or Rocky Linux 9. The install docs list those as the tested distributions, on x86_64 and ARM64.
  • Docker Engine with the Compose plugin.
  • Node.js 20 or newer on the host, because the ./kritt CLI runs on the host rather than inside a container.
  • One model provider: a Codex login, or OPENAI_API_KEY, CODEX_API_KEY, ANTHROPIC_API_KEY or OPENROUTER_API_KEY.
  • GITHUB_TOKEN only if you plan to scan private repositories. The shipped .env.example states it plainly: a GitHub token alone cannot run scans.

Install Docker and Node 20 first

curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER

Log out and back in so the new group membership applies, then confirm the Compose plugin is present.

docker compose version

A version string means Compose is installed as a plugin. docker: 'compose' is not a docker command means you have the old standalone docker-compose binary instead, and open-kritt calls docker compose. Membership of the docker group is equivalent to root on the host, so put only the account that runs open-kritt into it. For the longer version of that setup, see running Docker on a VPS.

Ubuntu 24.04 ships Node 18 in its own repository, and the CLI exits on anything below 20. Use NodeSource.

curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt install -y nodejs
node -v

node -v must print v20. or higher. On Rocky Linux 9 the equivalent is sudo dnf module enable nodejs:20 -y followed by sudo dnf install -y nodejs.

Clone open-kritt and pin a tagged release

git clone https://github.com/Kritt-ai/open-kritt
cd open-kritt
git fetch --tags
git tag --list
git checkout v1.3.0

main moves under you. A tag does not. As of August 2026 the newest tag is v1.3.0, published on 4 August 2026, and git tag --list shows what exists on the day you clone. Checking out a tag leaves the repository in detached HEAD state, which is correct here: you are treating this clone as a pinned deployment, not as a branch you commit to. To upgrade later, read the release notes, then run git fetch --tags, check out the new tag, and run ./kritt start again, since start rebuilds the images.

Do not run ./kritt with sudo. The documentation is explicit about it. The CLI manages project-local credential directories under .data/, so a run as root leaves those directories owned by root and the next normal run cannot write them.

Configure model access with ./kritt setup

./kritt setup

The command creates .env from .env.example when it does not exist, prints the status of each credential, and lets you set or unset them. It never prints the values back to the terminal. Both .env and the engine credential file are written with mode 0600.

If you would rather do it by hand:

cp .env.example .env
chmod 600 .env
mkdir -p .data/codex
chmod 700 .data/codex

Then edit the provider key into .env and leave the file at 0600. Either way, a working provider credential now sits on that server, which is one more reason the box should hold nothing else. Create a key for this project alone, so revoking it later breaks nothing you care about. Keeping secrets out of the reach of AI agents covers the wider habit.

Set a provider spending cap before the first scan

open-kritt is built to fan out, and the fan-out is what you pay for. The defaults in .env.example at v1.3.0 are conservative: ENGINE_WORKER_COUNT=2, described in the file as a conservative default for a small 2-vCPU machine, and ENGINE_MAX_CONCURRENT_SCANS=1. Above those sits ENGINE_WORKERS_PER_ACCOUNT=15, the maximum concurrent root model calls allowed on one provider account, and ENGINE_CODEX_MAX_SUBAGENTS_PER_SESSION=5, because a Codex session may run up to five child agents. Raise the worker count on a larger VPS and the number of model calls in flight rises with it.

Nothing in the repository caps what you spend. There is no budget setting in .env.example. The engine's own stop conditions are those worker limits plus ENGINE_HARNESS_TIMEOUT_SECONDS, which defaults to 7200 seconds per harness run. So the ceiling has to live at the provider. Open your provider console and set a hard monthly limit before the first scan, not after it. Controlling what an AI agent costs you on a VPS walks through the per-provider settings.

There is a local brake too. Setting ENGINE_WORKER_COUNT=0 pauses pickup of new jobs, and the same worker values can be changed in the Settings screen once the stack is running.

This guide quotes no price per scan, because the cost depends on the repository size, the workflow you build and the model behind it. Run one scan against one small repository, then read your provider's usage page before you point it at anything large.

Start the stack and check it is healthy

./kritt start

That checks .env and at least one credential, then runs docker compose up --build. The first build is slow, because it builds the frontend, backend, engine, executor view and database images. It also runs in the foreground, so closing the SSH session stops the stack. Start it inside tmux, or bring it up detached once the first build has succeeded.

docker compose up -d --build
docker compose ps

docker compose ps should list open-kritt-frontend, open-kritt-backend, open-kritt-engine, open-kritt-executor-view and open-kritt-db. Then check that the backend answers on the server itself.

curl -s http://127.0.0.1:3002/api/health

A JSON response means the backend is up. Failed to connect to 127.0.0.1 port 3002: Connection refused means it is not, and docker compose logs backend will say why. Stop everything with docker compose down from the repository directory.

One optional extra: docker compose exec backend npm run seed loads demo data, which is a cheap way to look at the interface before you spend anything on a real scan.

Reach the UI on port 5173 through an SSH tunnel

Every service in the compose file binds to 127.0.0.1 by default: the frontend on 5173, the backend on 3002, the executor view on 8090 and Postgres on 5432. Leave those bindings alone and forward the port over SSH from your own machine.

ssh -N -L 5173:127.0.0.1:5173 you@your-server-ip

Open http://localhost:5173 in your local browser while that command runs. -N means the connection carries the forward and no shell. Add a second -L 8090:127.0.0.1:8090 to the same command when you want the executor view as well.

The temptation is to set FRONTEND_BIND_ADDRESS=0.0.0.0 and skip the tunnel. Do not. The backend has no login screen, so anyone who reaches that page can start scans and spend your provider credit. There is a second trap underneath it: a published container port is handled before ufw's default policy applies, so a ufw deny 5173 rule looks right and blocks nothing. Docker ports that bypass ufw shows the rule chain that causes it.

Sizing the VPS

ENGINE_MIN_FREE_STORAGE_GB defaults to 20, and the engine refuses to start a new per-job scan container when free storage falls below it. The built images, the checkout cache, the Postgres data and the job workspaces all live on the same disk, so a 20 GB VPS never starts a scan at all. Treat 40 GB as the floor, and give it more if you scan large repositories.

Memory follows simple arithmetic. ENGINE_MEMORY_RESERVE_GB=2 holds memory back for the engine, the database, the API and short-lived overhead, and each scan runner carries a reservation and a hard cap of ENGINE_SCAN_RUNNER_MEMORY_MB=1536. Two workers therefore want about 5 GB before anything else runs. The engine admits only the runners that fit in the remaining budget, so on a small box scans queue instead of failing, which is a far better failure mode than the out-of-memory killer.

Two prune settings default to true: ENGINE_AUTO_PRUNE_DOCKER_BUILD_CACHE and ENGINE_AUTO_PRUNE_UNUSED_DOCKER_IMAGES. After a task completes, the engine removes unused build cache, unused images and stopped scan containers. Images referenced by a running container, bind mounts, database data, credentials and volumes are preserved. It is still one more reason not to share the host: a pruner you did not configure is running against that Docker daemon.

The engine settings most people end up changing
  • ENGINE_WORKER_COUNT: total worker slots shared by scan steps and post-processing. Set it to 0 to pause pickup of new jobs.
  • ENGINE_MAX_CONCURRENT_SCANS: how many scans are admitted at once. Queued scans wait until the active pool is empty.
  • ENGINE_MAX_WORKERS_PER_SCAN: 0 shares the aggregate slots evenly between scans.
  • ENGINE_HARNESS_TIMEOUT_SECONDS: 7200 by default. This is the longest a single runaway job can last.
  • ENGINE_MIN_FREE_STORAGE_GB: the storage floor. ENGINE_IGNORE_LOW_STORAGE=true disables the safeguard, and the file warns that this can fill the host disk.
  • ENGINE_SCAN_RUNNER_MEMORY_MB: hard memory cap per runner. 0 removes the cap.

Scanning a local repository without leaking it

LOCAL_REPOS_PATH defaults to ./local_repos and is bind-mounted into the backend and engine containers at /local_repos, so a repository you drop into that folder on the host appears inside the containers immediately. Use a fresh clone, not your working tree. The job container gets a writable copy, root inside itself, and outbound internet access, which means anything sitting in that copy can be changed or sent off the box. Strip .env files and private keys before you copy a project in.

What you get back, and what you do not

You get ranked candidate findings. You do not get verified vulnerabilities. Ranking and de-duplication decide the order of your triage queue. They do not prove that an entry is real. Post-scripts can attempt validation and build a proof of concept, and that is the strongest signal the tool offers, but a post-script that fails is not evidence the finding is false. A person still reads every candidate.

This guide makes no claim about how many real bugs open-kritt finds, because we have not measured it. Anyone quoting a detection rate for your codebase has not run it against your codebase. Scan a repository you already know well first: findings you can judge yourself are the cheapest calibration available.

Authorization matters more here than with most self-hosted tools. The agents compile and execute code and reach the network, so a proof-of-concept step can touch live systems. Point it at code you own or are contracted to test, and write the target scope down before you run anything. If you configure ANTHROPIC_API_KEY and use the Claude Code engine, the sandboxing habits in running Claude Code safely on a VPS apply to these agents too.

FAQ

Why does open-kritt need its own VPS?

Because its analysis agents run as root inside disposable job containers with writable copies of your code and direct internet access, and because the engine service mounts the host Docker socket so it can launch one container per job. Any process that reaches that socket can start a container that mounts the host filesystem, so the whole stack should be treated as root on its host. On a dedicated VPS that is an acceptable trade, and rebuilding the box costs you nothing. On your daily workstation it puts your SSH keys and browser profiles inside the same trust boundary as code you are scanning.

Can I expose port 5173 instead of using an SSH tunnel?

You should not. The backend ships without application authentication, so the port is the only thing between the internet and your findings and provider credit. The compose file binds every service to 127.0.0.1 for that reason. Run ssh -N -L 5173:127.0.0.1:5173 you@your-server-ip and browse to http://localhost:5173 locally instead. A ufw rule is not a substitute, because a published Docker port is handled before ufw's default policy applies.

How do I stop open-kritt from spending more than I planned?

Set a hard limit in your model provider's console before the first scan, because open-kritt has no budget setting of its own. Keep the shipped concurrency defaults for the first few runs, ENGINE_WORKER_COUNT=2 and ENGINE_MAX_CONCURRENT_SCANS=1, and remember that one provider account allows up to 15 concurrent root model calls by default while a Codex session may run up to five child agents. ENGINE_WORKER_COUNT=0 pauses pickup of new jobs and is the fastest local stop.

Which version should I check out?

A tag, never main. git fetch --tags followed by git tag --list shows what is available, and v1.3.0, published on 4 August 2026, is the newest as of this writing. Pinning means a rebuild months later produces the same stack, and it makes upgrading a decision you take after reading release notes rather than a side effect of cloning on a different day.

A scan never starts. What should I check?

Check free disk first, since the engine will not launch a per-job scan container when free storage is below ENGINE_MIN_FREE_STORAGE_GB, which defaults to 20 GB. Then check that ENGINE_WORKER_COUNT is not 0, because that value pauses pickup of new jobs. Then confirm a model credential is really configured by running ./kritt setup, because a GITHUB_TOKEN on its own cannot run scans. docker compose logs engine names the reason it skipped the job.