Skip to main content
A VPS you manage yourself — a DigitalOcean droplet, an EC2 instance, a Hetzner box — differs from a managed platform in one way that matters: there is no dashboard to paste secrets into, and no build step that runs on your behalf. You own the whole boot path. Two approaches work. Pick based on whether the box should hold a deploy token or a person’s identity. The same model as every managed platform. The droplet holds two environment variables and nothing else. On your laptop, in the project directory:
On the droplet, write them to a root-owned file — not into the container, and not into the project:
Bake the CLI into your image and let it be the entrypoint:
Then run it:
Use --init so the container gets a real PID 1. capy run forwards SIGINT, SIGTERM, and SIGHUP to your process, but it is not a process reaper — pair it with Docker’s init if your app spawns children. Neither value in that file is a secret on its own. SECRETS_BLOB is inert without a live call to the Capy service, the service refuses revoked tokens, and no plaintext ever touches the droplet’s disk. To rotate: re-run capy deploy, replace the file, restart the container. To cut access: capy deploy revoke <deployId>.

Option 2: Droplet-resident identity

Sometimes you want the droplet to be a member of the org — every decryption attributed to a person, secrets updated with capy the same way as on a laptop. This trades availability for auditability. Read the tradeoff before choosing it.

Logging in without a browser

capy signs in through your browser with a callback on localhost. A headless droplet has no browser, so forward the callback ports over SSH and use the browser you already have. The CLI takes the first free port in the range 1942019424, so forward all five:
Inside that SSH session:
The CLI prints an authentication URL and tries to open a browser, which fails on a headless box — that is expected. Paste the URL into your laptop’s browser instead. The redirect lands on your laptop’s localhost, the tunnel carries it to the droplet, and the CLI completes the flow. You have five minutes before it times out. This is a one-time step. Afterwards the droplet holds a session and refreshes it silently. You now have keep.lock and an encrypted .env in /srv/myapp. Both are safe at rest — every value in .env is ciphertext.

Wrapping the container

capy run cannot inject into a container that is already running, so it has to be what launches the container:
List the variable names your container should receive, with no values, so Compose passes them through from capy run’s environment:
Variable names are not secrets, and listing them is an explicit statement of what the container is allowed to see. Nothing plaintext is written to disk on the host or baked into the image. Deploying an update becomes two commands — capy to pull the latest secrets, then capy run -- docker compose up -d.
You may be tempted to bind-mount ~/.capy into the container and run capy run as the container’s entrypoint instead. That works, but it hands the container your full org identity — a container compromise becomes an account compromise. Keeping the CLI on the host means the container only ever receives its own variables.

Which one

The deciding question is what happens when the person leaves. With a droplet identity, their session ends and containers stop booting on their next restart — production is coupled to an individual’s account. Deploy tokens have no such coupling, which is why they are the default recommendation for anything load-bearing. Both modes call the Capy service once at container start. If that call fails the container does not start, in exchange for revocation that takes effect on the very next boot rather than whenever a cached key expires. Once running, a service outage is invisible to your process.

See also

Docker

Entrypoint patterns, Compose, and Kubernetes.

capy run

What each mode needs at startup.
Last modified on August 11, 2026