For a while now, SSH in my homelab has worked through short-lived certificates. Vault holds the SSH CA, Authentik handles my identity, and the targets trust the CA instead of a growing pile of authorized_keys entries. I wrote about that pattern in Implementing SSO for SSH, and it has held up well.
What it did not give me was one place to answer a simple question: who can reach which machine right now, and what did they do there? The answer was spread across Vault roles, principals on the targets, my local SSH configuration, and firewall rules. Each part was reasonable on its own. Together, they were hard to review.
I use Warpgate from my workstation now. It gives me one place to manage targets and permissions, approve connections, and review sessions. Vault still signs the SSH certificates underneath.
What the gateway does
The connection path looks like this:
Workstation (or another client)
| signs in to Warpgate (Authentik SSO for the web UI)
v
Warpgate on the LAN
| registered targets, roles, optional per-connection approval
| ephemeral upstream key + Vault-signed certificate
v
Target's existing OpenSSH serverWarpgate keeps a central inventory of registered targets. A user or service account gets access through roles, and For the important targets, a connection doesn't go through on its own: Warpgate holds it until I approve it in the web interface. Every session is logged and recorded on the gateway, so I can see afterwards who connected where and what they did.
When I connect to a target, Warpgate authenticates me, checks my role, and then opens the upstream connection itself. For that upstream hop it generates an ephemeral key, has Vault sign it, and presents the resulting certificate to the target. The credentials are injected on the gateway. On this path, neither I nor the agent hold a key or certificate for the target itself, only for the gateway. The targets didn't need any change for this: they trust the same CA as before, and nothing Warpgate-specific runs on them.
From my workstation that means I sign in to the web interface through Authentik, and I open SSH sessions through the gateway by naming the target in the username, the way Warpgate expects:
ssh -p <gateway-port> <user>:<target>@warpgate.example.invalidThe target name is the one registered in Warpgate. I no longer need to keep that list in sync with my local SSH configuration.
The old path is still there, on purpose
I did not tear out the existing workflow. My normal SSH and Vault setup still works, and I still have the option to use it directly. That is deliberate: Warpgate is now a shared dependency, and I want an administration path that does not depend on the component I might be trying to repair.
The fallback is a choice I make, not something that happens automatically. If Warpgate denies a connection, nothing quietly retries over the direct route.
The network enforces this too. Port 22 on the targets only accepts the gateway, my workstation and the bastion host, so being on the LAN isn't enough to reach a server directly. For the isolated networks the rule lives in VyOS, as in the VyOS post. For servers on the LAN it has to be in each host's firewall, because traffic inside one subnet never passes the router. Two small LAN hosts and one external server aren't covered yet.
A step toward the zero-trust homelab
I want to get my homelab closer to zero trust. Being on the LAN should not be enough to SSH into a server; I want to know who is connecting and why that account has access.
Warpgate helps with that. A login gets me into the gateway, but my roles still decide which targets I can use. I can require approval for a sensitive target, and Vault issues a short-lived certificate for the upstream connection. The isolated agent has to pass those checks too, even though its firewall rules let it reach the gateway.
Why Warpgate
I wanted something self-hosted and always on, with a web interface, SSH proxying, roles, and session recording. Warpgate is Apache-2.0 licensed and runs comfortably as a Docker Compose stack, which matches how the rest of my services are deployed.
I also looked at Boundary. The credential-injection feature I needed was not part of the community edition I was evaluating; it required an HCP or Enterprise offering. For a free, self-hosted homelab setup that was a mismatch, not a judgement on Boundary in general.
The part that mattered most to me: since 0.30, Warpgate has secret backends for Vault and OpenBao, and an SSH target can use a certificate signed by one of them. I didn't want a second CA just for the gateway, and I didn't want to distribute a new gateway public key to every target. With the stock image I need neither.
Reusing Vault without widening it
Under the existing SSH secrets engine I added one signing role for the gateway. My personal role stayed separate.
The gateway's policy is short:
path "ssh/sign/warpgate" {
capabilities = ["update"]
}
path "ssh-infra/sign/warpgate" {
capabilities = ["update"]
}
path "auth/token/lookup-self" {
capabilities = ["read"]
}The second path belongs to the CA for my core servers, which I explain further down. The last one is only there for Warpgate's health check. The AppRole doesn't get the default policy, so without that line the check fails with a 403.
Tokens live for 15 minutes, with a 30-minute maximum, and both tokens and Secret IDs only work from the gateway's address.
The signing role is narrower again. It issues user certificates for one non-root account, with only the permit-pty extension. I didn't add forwarding to make a convenience tunnel work. I also checked the other direction: the gateway identity can't sign through my personal role, and it can't request a root principal. Those failures were as important to test as the successful login.
The certificates are valid for five minutes. I would have liked three, but Warpgate always asks Vault for five, and Vault rejects a TTL above the role's maximum instead of shortening it. So the role allows five. Two more minutes on a certificate that is used for a single connection is fine for me.
The backend is configured in Warpgate's admin interface, not in the YAML file. It holds the Vault address, the AppRole credentials and the list of paths the gateway may use. Each target names its backend, mount and role:
{"kind": "certificate", "backend": "vault", "mount": "ssh-infra", "role": "warpgate"}HTTPS verification to Vault stays on. Warpgate also checks the certificate it gets back: principal, public key, key ID and lifetime have to match what it asked for. The key ID contains the Warpgate user and session, so the auth log on the target shows who was behind a login, not just that the gateway connected.
The Secret ID
The gateway needs to log in to Vault without me, so it holds an AppRole Secret ID. Warpgate keeps it encrypted in its own database. It isn't in a file on the host or in the image.
Each Secret ID expires after seven days, and a small job replaces it every night. That job runs on the Vault host, not on the gateway. Whoever can create new Secret IDs can outlast any revocation, so that right shouldn't sit on the machine the Secret ID is meant for.
A run looks like this:
- Create a new Secret ID for the gateway's AppRole.
- Log in to Warpgate with a dedicated account and store the new Secret ID in the backend.
- Trigger Warpgate's health check, which logs in to Vault with the new Secret ID from the gateway.
- If that works, destroy every other Secret ID of the role.
If Warpgate never got the new Secret ID, the job deletes it and the old one keeps working. If something fails after that, it keeps both and the run fails, so I see it in the logs instead of losing access.
The job gets as little as I could give it. Its Vault token can create, list and destroy Secret IDs for this one role and renew itself. It only works from the Vault host's address. In Warpgate, its account can edit configuration and nothing else: no users, no targets, no sessions.
A second CA for the core servers
With one CA, every certificate Vault signs is valid on every server. That includes my own certificates, the ones for a deploy pipeline and the ones the gateway requests. For most machines that's fine. For the few that hold everything else together, I wanted a stricter line.
So there's a second SSH mount in Vault with its own CA. The gateway, the reverse proxy, monitoring, Vault and a couple of other core servers trust only that one. Everything else keeps the old CA. In Warpgate, the core targets use that mount and the rest use the old one.
My personal role exists on both mounts and is kept tight: one principal, one hour, no agent forwarding. The shell function that signs my key gets a certificate from each CA, and SSH offers both.
This doesn't help if the gateway itself is taken over, because it can sign for both. What it does prevent is a certificate meant for an app server or a deployment opening a shell on the core servers.
Two machines aren't behind Warpgate at all: the Vault host and the Proxmox host. Root on either of them is effectively root on everything, and a compromised gateway shouldn't have a path there. I reach both directly from my workstation with a certificate, and if SSH ever breaks, there's still the Proxmox console. On Proxmox, root can't log in over SSH. I log in as a normal user and use sudo.
What it costs to put everything in one place
Concentrating identity, permissions, approval, and credentials in a single gateway makes access easier to reason about. It also means the gateway is the thing to protect.
If the gateway is compromised, an attacker can use its Vault signing rights: certificates for that one account, on any target that trusts one of its two CAs. Vault has no way to verify which Warpgate user a signing request is for; it trusts the gateway's identity.
The short lifetimes, the single principal, and the single signing path limit what the gateway can do, but they don't remove the trust.
Revocation also has edges. Revoking the Secret ID stops new logins to Vault, but it does not invalidate tokens the gateway already holds, certificates already issued, or sessions already open. Each of those needs its own response. A Secret ID copied off the gateway at least stops working with the next nightly rotation. The gateway's data, recordings, and backups are now worth protecting as well.
For me, the useful part is being able to review access in one place. Keeping that gateway patched, restricting its signing rights, and protecting its data are now part of running it.
Publishing the web interface
Warpgate runs under /opt/warpgate with Docker Compose, persistent data and recordings, and a restart policy. The web interface goes through Traefik with its own dynamic configuration file, so the browser gets a normal valid certificate.
The backend certificate is self-signed. I gave that single Traefik backend a narrow transport exception instead of turning off verification globally. Backend HTTPS accepts connections only from Traefik and, for the rotation job, from the Vault host. That rule exists both in the Docker DOCKER-USER chain and in the host INPUT rules, because a port published by Docker does not necessarily pass through INPUT. The web interface itself is reachable only from two workstations.
Authentik remains the login authority for humans. Warpgate needed a verified email claim in addition to the usual openid and profile scopes. I kept SSO required instead of falling back to password logins; the local recovery credentials were only used on a test clone.
Automation gets the same front door
Once the gateway existed for my own access, it also turned out to be a better path for automation, including the AI agent I run in an isolated VM.
The agent used to need a per-target SSH alias, which meant a second inventory next to Warpgate's. The agent never gets a plain SSH client. It goes through a small wrapper I wrote, which shows me every command and runs it only after I approve it. I gave that wrapper an optional Warpgate mode. With it, the agent has one gateway alias with a dedicated key, and a small config file:
{
"enabled": true,
"gatewayAlias": "warpgate-gateway",
"username": "ai_worker"
}The agent asks for a destination by its Warpgate name:
{"destination": "analytics", "command": "id -un; hostname"}and the wrapper turns that into a Warpgate route:
ssh -l ai_worker:analytics -- warpgate-gateway <approved-command>The agent's account is a non-admin service user with its own role. It holds a key for the gateway and nothing else: no upstream target key, no Vault token, no Secret ID. Targets still have to be registered, have their host key verified, and be granted to that role in Warpgate. There is no auto-registration, and the agent has no admin token to add destinations itself. A malformed config fails closed instead of falling back to a direct address, and the route is resolved again right before the command runs.
On the network side, the VyOS isolation still applies. The agent VM has no LAN interface. The new access exception permits only its connection to the gateway's SSH port; it doesn't open general LAN access. Direct Vault, direct target SSH, the gateway's management SSH, and its HTTPS backend were all blocked when I checked.
Per-connection approval fits nicely here. On a sensitive target, Warpgate holds the agent's session until I approve it in the dashboard. That is separate from the wrapper's own prompt, which asks me locally about the exact command. Neither one replaces the other, and approving the connection in Warpgate is not an approval of the specific command.
That code is installed and tested offline. The full interactive run from the agent and a live session held for approval are still on my list.
Where this leaves me
I now have one place where I can see which targets exist, who can reach them, which connections need my approval, and what happened in each session. I use it from my workstation, it reuses the CA my servers already trust, and the old direct path is still there when I need it.
There is still work to do before I would call the homelab zero trust. Next on the list are the last few hosts without the firewall rule, and an alert for the rotation job. Right now it only tells me about failures in its log.