Most ways of managing your own servers from a web panel start the same way: you hand the panel an SSH key, usually with root behind it. From then on, whoever compromises the panel has a shell on every server you own.
I wanted to see how far you could get without that key, so I started Shipkeep: an open-source (Apache-2.0) platform to deploy apps on your own servers, without Kubernetes, in TypeScript and Go.
It can't be installed yet, and I'll be precise about that below. I'm writing this because the next step needs people other than me.
The idea: the server calls out
The control plane never connects to your servers. Each server runs an agent, and the agent opens the connection:
You (web UI, CLI) ------------------> Control plane Agent on server A --outbound mTLS---> Control plane Agent on server B --outbound mTLS---> Control plane To add a server, an admin generates a join token that works once and expires after 15 minutes. The agent generates its own Ed25519 key pair and sends a certificate request with that token. It gets back a 30-day client certificate that only identifies that node.
So there is no SSH key to steal from the control plane, and your servers don't need an inbound port for it.
The agent only knows a closed list of actions
An outbound connection isn't enough if the control plane can then send sh -c "whatever". So the agent has no generic command. Everything it can do is a typed message in one protobuf file:
// Anything not defined here does not exist: there is no generic // exec, shell or file-write action. message PullImage { // Must contain `@sha256:`. string image_ref = 1; RegistryCredentials registry_credentials = 2; } message RemoveRoute { string route_id = 1; google.protobuf.Duration drain_timeout = 2; } The agent talks to Docker through the Engine API and to Caddy through its admin API. Commands are argv arrays, never shell strings. Interactive exec exists, but only inside managed containers. There is no shell on the host.
Adding an action to that list requires agent-side validation and tests with malicious input: path traversal, injection characters, oversized values.
What this doesn't solve
A compromised control plane is still a serious incident. It can't open a shell on your hosts, but it could ask agents to run a malicious container, and Docker access is powerful. This design shrinks what an attacker gets and makes it auditable. It doesn't make the problem disappear.
If you see a hole in that reasoning, the threat model is public, and telling me what it misses is a real contribution.
Where it actually stands
| State | What |
|---|---|
| Built and tested | Accounts, 2FA, roles, audit log, CLI, most of the web UI |
| Unit tested, never run on a real server | Agent, deployment pipeline, rollback, Caddy routing |
| Draft | Installer |
| Not built yet | Git providers, builds, WireGuard network, failover, managed databases, backups, logs and metrics, previews |
No deployment has gone past queued with the real worker and agent yet. That's the milestone I'm working toward.
I'm the only maintainer for now, and I use AI coding assistants a lot. That's one more reason I want other people reading this code.
Where you can help
- You have a disposable Ubuntu 24.04 VM: run the installer draft or try the first real deployment. A report saying "it broke at this step" is exactly what I need.
- Go: fuzz the agent's action validators.
- Security: read the threat model and try to break the reasoning. Please report vulnerabilities privately, as described in SECURITY.md.
- First contribution: six small good first issues are ready.
The call for contributors lists everything else, including TypeScript and Angular work. There's no CLA, just git commit -s.
And if you've run your own servers from a panel: what would you need to see before trusting one? I'd really like to know in the comments.

