Skip to Content
DocsDeploymentDockerAdd to a container

Add Cmdop to a container

The usual reason to reach for Docker here is not “run Cmdop in a container” — it is “the container I already ship should be reachable.” That takes two lines, and it changes nothing about how your image behaves.

TL;DR

COPY --from=markolofsen/cmdop:latest /cmdop /usr/local/bin/cmdop, then ENTRYPOINT ["cmdop", "sidecar", "--"] in front of your existing CMD. Set CMDOP_SERVER_URL, CMDOP_JOIN_KEY, CMDOP_MACHINE_NAME, and put HOME on a volume. Your application stays PID 1.

The two lines

FROM your-image # unchanged COPY --from=markolofsen/cmdop:latest /cmdop /usr/local/bin/cmdop ENTRYPOINT ["cmdop", "sidecar", "--"] CMD ["your-app", "--your", "flags"] # unchanged

Then hand the container the fleet’s join key:

docker run -d \ -e CMDOP_SERVER_URL=https://your-team.cmdop.dev \ -e CMDOP_JOIN_KEY=cmdop_enroll_xxxxxxxx \ -e CMDOP_MACHINE_NAME=api-01 \ -e HOME=/state -v cmdop-state:/state \ your-image

The machine appears in your fleet on startup, with terminal, file access, AI chat and remote execution — from a browser, the CLI, or your phone.

A working version of exactly this, on an ordinary Node app, is examples/simple in the public repository.

What sidecar does

sidecar starts the agent in the background, then execs your command. That word is the whole design: your process replaces the wrapper and becomes PID 1.

  • docker stop delivers SIGTERM to your application, so your graceful shutdown runs unchanged.
  • The container’s exit code is your exit code.
  • restart: policies behave exactly as before.
  • Everything after -- passes through untouched.

Three consequences worth knowing before you ship it:

A failing agent never blocks your appWrong key, unreachable relay, no network — it is logged, and your command still starts.
No join key, no agentWith the variables unset, sidecar is a plain exec. The same image runs unchanged where it was never enrolled.
Nothing listensThe agent dials the relay outbound. No inbound port is opened and no ingress rule is needed.

Where the relay is

CMDOP_SERVER_URL points at a relay — the thing machines join and you connect through. A container added this way is a member: it dials out, and it never listens. Something else has to be the relay:

The relay is…CMDOP_SERVER_URLWhen
your own machinehttp://host.docker.internal:63141trying this out — run cmdop server on your laptop and the container dials back to it
a machine you hosthttps://<your-host>:63141you already run Cmdop on a server
the managed tierhttps://<your-team>.cmdop.devyou want an address without hosting one

In every case the relay is where cmdop server join-key prints the key that goes in CMDOP_JOIN_KEY — the two settings always come from the same place.

Hosting a relay is a different job from joining one: a relay listens, holds server.db, owns the console password, and on the managed tier registers a public address. See self-hosted relay.

Two credentials, and they are not the same thing

This is the part that trips people up. Joining the fleet and paying for inference are separate.

CredentialWhat it doesWhere to get it
CMDOP_JOIN_KEYlets this machine join the fleetcmdop server join-key on the relay host
CMDOP_ROUTER_API_KEYpays for inference — the AI partmy.cmdop.com 

With the join key alone the machine connects and the terminal and file access work fine; AI chat just has nothing to think with.

The join key is a durable shared secret for the whole fleet: anyone holding it can add a machine. Keep it out of shell history, images, and logs, and rotate it from the console’s Server → Security if it leaks. Rotating keeps already-joined machines connected.

Settings that are not optional

SettingWhy
CMDOP_MACHINE_NAMEWithout it the name comes from the container hostname, which Docker regenerates on every recreate — the fleet fills with hex-named strangers.
HOME on a volumeIdentity, credential and state live there. Without it, every recreate is honestly a new machine to the relay.

One rule about that volume: it is for state, never for executables. A binary installed into a volume is seeded once, at first container creation, and then outlives every later image build — so a rebuild silently keeps running the old one. Copy cmdop to an image path, as the Dockerfile above does.

Other settings

VariablePurpose
CMDOP_MACHINE_PINOnly when the relay operator armed a per-machine connection PIN.
CMDOP_SERVER_INSECURESet 1 only for a self-hosted relay with a self-signed certificate.

Any base image

The binary links no libc, so Alpine, Debian, distroless and even scratch all work. Nothing else needs to be present in the image.

The image, and why it has one tag

markolofsen/cmdop is a source of the binary, not something you run: it carries no shell and adds only the file it copies. It is built for linux/amd64 and linux/arm64 from the published release binary, verified against its SHA256SUMS.

:latest is the only tag, and that is deliberate — the agent keeps itself current at runtime, checking daily and applying updates in place, so a container built months ago runs today’s version without a rebuild. Your application image stays fixed; the management agent does not. It is the same bargain Tailscale, the Datadog agent and cloudflared make.

If your deployment must be immutable, that default is switchable:

CMDOP_NO_AUTO_UPDATE=1No background checks, ever. The version is whatever the image shipped.
CMDOP_PIN_VERSION=v1.2.3The updater resolves to exactly that version and refuses anything else. Also a rollback switch.

Fetching the binary at build time instead

If you would rather not pull from a registry, the installer works too:

RUN curl -fsSL https://install.cmdop.com | sh -s -- --prefix=/usr/local/bin

On Alpine that needs apk add --no-cache curl ca-certificates first; Debian-family images usually have both. The COPY --from form is preferred because it fetches nothing at build time and its layer is cacheable.

Last updated on