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.
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"] # unchangedThen 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-imageThe 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 stopdelivers 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 app | Wrong key, unreachable relay, no network — it is logged, and your command still starts. |
| No join key, no agent | With the variables unset, sidecar is a plain exec. The same image runs unchanged where it was never enrolled. |
| Nothing listens | The 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_URL | When |
|---|---|---|
| your own machine | http://host.docker.internal:63141 | trying this out — run cmdop server on your laptop and the container dials back to it |
| a machine you host | https://<your-host>:63141 | you already run Cmdop on a server |
| the managed tier | https://<your-team>.cmdop.dev | you 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.
| Credential | What it does | Where to get it |
|---|---|---|
CMDOP_JOIN_KEY | lets this machine join the fleet | cmdop server join-key on the relay host |
CMDOP_ROUTER_API_KEY | pays for inference — the AI part | my.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
| Setting | Why |
|---|---|
CMDOP_MACHINE_NAME | Without it the name comes from the container hostname, which Docker regenerates on every recreate — the fleet fills with hex-named strangers. |
HOME on a volume | Identity, 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
| Variable | Purpose |
|---|---|
CMDOP_MACHINE_PIN | Only when the relay operator armed a per-machine connection PIN. |
CMDOP_SERVER_INSECURE | Set 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=1 | No background checks, ever. The version is whatever the image shipped. |
CMDOP_PIN_VERSION=v1.2.3 | The 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/binOn 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.