# Host control channel
URL: https://openship.io/docs/troubleshooting/host-channel.md

"This operation targets the HOST machine, but no host channel is configured" — what the host channel is, why the rest of the install looks healthy, and the repair for each install shape.

import { Step, Steps } from 'fumadocs-ui/components/steps';

When Openship runs in a container it builds and runs your apps through the Docker socket — but a
container can't operate the machine underneath it. For the handful of jobs that need the host itself,
Openship keeps a private SSH channel to it: an ed25519 key authorized for one user, dialed over the
internal `host.docker.internal` bridge and never over a public address.

This page is about the state where that channel was never built. The install reported success, the
dashboard looks fine, ordinary deploys work — and then one specific operation fails with this:

<Callout type="error">
```
This operation targets the HOST machine, but no host channel is configured
(OPENSHIP_HOST_SSH_HOST is unset) and Openship is running in a container — so it
would have run inside the container instead, against the wrong filesystem.
Re-run `openship up` to provision the host channel. `openship doctor` reports
whether it worked.
```
</Callout>

You can also meet it before anything fails: as a `!!! HOST CONTROL NOT CONFIGURED` banner in the API's
boot log, as a **Host control** row reading *never provisioned* in `openship doctor`, or as a banner on
the server's screen in the dashboard.

<Callout title="Where this lives">
Nothing here is a dashboard setting — the channel is **CLI-only on purpose**, because a toggle in the
dashboard would be reachable by the very thing it defends against. The install-time context is in
[Installation → Host control](/docs/getting-started/installation#host-control-hardening); the commands
are in the [CLI reference](/docs/cli/self-host).
</Callout>

## This is degraded, not broken

Read this before you touch anything, because the instinct to rebuild the box is the wrong one:

- **Ordinary deploys to this box still work — they go through the Docker socket.** Building, running,
  restarting and routing your apps needs the socket, not this channel.
- **Blocked until the channel works:**
  - the host terminal, host system info and host port scans
  - taking over `:80`/`:443` from a proxy Openship didn't start
  - installing or updating the mail engine
  - deploying a catalog app that needs a generated config file on the host (Supabase's `kong.yml`, say)

One more symptom belongs next to those but isn't a lost feature: this box also reads **Offline** in the
dashboard's server list while the channel is down. That's the same break wearing a scarier label — the
box is up, the control plane just can't reach it the one way that check uses.

<Callout title="Why everything else looks healthy" type="info">
Because it *is* healthy. The channel's address is host-**local**, so it traverses the host's
`filter/INPUT` chain, where a default-deny firewall applies. Published container ports (`:4000`,
`:3001`, `:80`, `:443`) are DNAT'd and never reach that chain. Every check that goes through a published
port passes; only this one hangs.
</Callout>

## Repair — it depends how you installed

The two Compose install shapes look nearly identical on the box and have **different** repairs. Applied
to the wrong one, the first does nothing and the second can cost you every stored environment variable.
Check which you have:

```bash
ls ~/.openship/compose/docker-compose.yml   # exists → the CLI installed this stack
```

### Installed with `openship up`

One command — the same one the error names:

```bash
openship up
```

It generates and authorizes the key, writes the environment variables, recreates the API container so it
reads them, and then **dials the host from inside that container** and prints what came back. If the
probe reports a dropped connection it offers to add the firewall rule for you (or takes
`--open-host-firewall` for a scripted run). Then [verify](#verify-it-worked).

The run is idempotent: your secrets, database volume and certificates are carried forward, and a channel
that already works is re-provisioned to the same key.

<Callout title="openship update does this too — but only when there's a newer release" type="info">
`openship update` regenerates the stack from the new template and re-provisions the channel the same
way, then re-probes it. That makes it the better command when you're also behind a version — an update
is exactly when a working channel silently stops working, because the recreated API container can land
in a different docker subnet than your firewall rule allows. But when you're already on the latest
release it exits at *"Already on the latest version"* without touching the stack, so on its own it is
not a repair. Use `openship up`.
</Callout>

### Installed from the raw `docker/docker-compose.yml`

<Callout type="warn" title="Do not run openship up on this box">
Both install shapes pin the Compose project name `openship`, so `openship up` here doesn't create a
second stack — it **adopts** yours, volumes and all, while generating a fresh
`~/.openship/compose/.env`. Current releases stop before writing anything (*"Refusing to continue: this
would regenerate secrets your existing install already has"*), which is the outcome you want. Releases
without that guard minted a new `BETTER_AUTH_SECRET` — the key your stored environment variables are
encrypted with — making **every one of them permanently unreadable**. `openship update` is not the
answer either: with no `~/.openship/compose` stack to reconcile it updates the CLI and leaves your
containers alone.
</Callout>

Provision the channel by hand instead. It's five steps, and the environment variables are the *last*
one — setting them alone gets you `Cannot read host SSH key` or a hung handshake.

<Steps>

<Step>
**Generate a key on the host** (not inside a container):

```bash
sudo mkdir -p /var/lib/openship/host-ssh
sudo ssh-keygen -t ed25519 -N '' -C openship-host-executor \
  -f /var/lib/openship/host-ssh/id_ed25519
```
</Step>

<Step>
**Authorize it for `root`**, restricted to the private ranges the docker bridge dials from. It has to be
root: the paths host operations touch are root-owned, so a lesser user yields a channel that connects
and then can't do the job.

```bash
sudo mkdir -p /root/.ssh && sudo chmod 700 /root/.ssh
printf 'from="172.16.0.0/12,192.168.0.0/16,10.0.0.0/8,127.0.0.1",restrict,pty %s\n' \
  "$(sudo cat /var/lib/openship/host-ssh/id_ed25519.pub)" \
  | sudo tee -a /root/.ssh/authorized_keys
sudo chmod 600 /root/.ssh/authorized_keys
```

`restrict` denies port forwarding, agent forwarding and X11; `pty` is added back because the host
terminal needs one. Without `from=`, that key is a general-purpose root login from anywhere sshd
accepts — on a VPS, the whole internet.
</Step>

<Step>
**Satisfy the two prerequisites** — sshd reachable from containers, and the host firewall open for them.
They're [below](#the-two-prerequisites-that-break-this-silently); on a box running `ufw` the firewall is
the step that's actually missing.
</Step>

<Step>
**Set the variables** in the `.env` you pass with `--env-file`:

```bash
OPENSHIP_HOST_SSH_HOST=host.docker.internal
OPENSHIP_HOST_SSH_USER=root
OPENSHIP_HOST_SSH_PORT=22
OPENSHIP_HOST_SSH_KEY=/run/secrets/openship_host_key
OPENSHIP_HOST_KEY_PATH=/var/lib/openship/host-ssh/id_ed25519
```

The last two are a pair: `OPENSHIP_HOST_KEY_PATH` is the key's path **on the host**, which
`docker/docker-compose.yml` mounts at `OPENSHIP_HOST_SSH_KEY` inside the API container — so no compose
file needs editing. It must be an **absolute** path; a relative one resolves against `docker/`, not the
directory you run `docker compose` from. Left unset, `/dev/null` is mounted in its place, which is what
lets the stack start on a box with no key at all.
</Step>

<Step>
**Recreate the API container.** `env_file:` is read when a container is *created*, so a restart changes
nothing and the fix appears to have done nothing:

```bash
docker compose --env-file .env -f docker/docker-compose.yml \
  up -d --force-recreate --no-deps api
```
</Step>

</Steps>

## Verify it worked

The repair happens on the host and the fault shows up in a container, so "the command succeeded" and
"the channel works" are genuinely different questions. Ask the second one:

```bash
openship doctor
```

The **Host control** row dials the host from inside the API container: `reachable at
host.docker.internal:22` is the pass. Anything else names the cause — a dropped connection (firewall), a
refusal (sshd), or a name that didn't resolve.

<Callout title="On a raw docker compose install, doctor reports less" type="info">
The dial above needs the CLI's own stack at `~/.openship/compose`, which a hand-run
`docker/docker-compose.yml` install doesn't have. There, the **Host control** row falls back to what the
API says about its own environment over the loopback API: `never provisioned — run openship up` (read
the warning above before you do), `reachable from the api container`, or the fault by name. It cannot
name an address, because nothing dialed one.

For the address and the exact firewall rule, use the API's boot log — it diagnoses the channel on every
start:

```bash
docker compose --env-file .env -f docker/docker-compose.yml logs api | grep 'HOST CONTROL'
```

No output is the pass; the banner is only printed when something is wrong. Or exercise the channel
directly — open the server's **Terminal** tab in the dashboard, which is this channel and nothing else.
</Callout>

## The two prerequisites that break this silently

Either of these leaves a channel that is fully configured and completely dead, which is why the error
says "not configured" long before anyone suspects the host.

### sshd must be running, and listening where containers can reach it

The channel is real SSH to the host's own daemon. A minimal cloud image or a container-optimized OS may
not run one, in which case there's nothing to connect to:

```bash
sudo systemctl enable --now ssh
```

Also check that `ListenAddress` in `/etc/ssh/sshd_config` isn't pinned to `127.0.0.1`. That looks like
sensible hardening and is invisible to every other test, because everything else reaches this box
through a published port. A connection *refused* (rather than a hang) is this one.

### The host firewall must allow container→host on the SSH port

This is the common cause. Traffic from the API container to `host-gateway` is host-local, so it goes
through `filter/INPUT` — where `ufw`'s default deny sits. Published container ports are DNAT'd and skip
`ufw` entirely, so nothing else on the box notices. The connection doesn't fail, it *hangs*, which looks
exactly like a bad key.

Allow the docker bridge subnets the API container dials from:

```bash
# ufw
sudo ufw allow from 172.16.0.0/12 to any port 22 proto tcp

# firewalld
sudo firewall-cmd --permanent --add-rich-rule='rule family="ipv4" source address="172.16.0.0/12" port port="22" protocol="tcp" accept'
sudo firewall-cmd --reload
```

`172.16.0.0/12` is Docker's default address pool — wider than one bridge, and correct for any of them.
A raw `iptables` rule works too but doesn't survive a reboot on its own; persist it with
`iptables-persistent` or your distro's equivalent. On a CLI install you don't write any of this
yourself: `openship up` and `openship update` read the API container's actual networks, print the
narrower rule, and offer to apply it (or take `--open-host-firewall` for a scripted run).

<Callout title="The rule is in place and it still doesn't work" type="info">
Something ahead of `ufw`/`firewalld` is dropping it — check Docker's own `DOCKER-USER` chain, and any
cloud provider firewall or security group. If the failure *changed shape* when you added the rule (a
hang became a refusal), the rule did its job and you're now on the sshd problem above.
</Callout>

## Turning the channel off on purpose

A box that only manages *remote* servers doesn't need a host channel. Switching it off is a hardening
decision, not a repair — everything in the blocked list above stays blocked:

```bash
openship up --no-host-control
```

That deletes the private key and revokes its `authorized_keys` line, so the box's attack surface
shrinks rather than just this install's behaviour. On a raw `docker compose` install the equivalent is
`OPENSHIP_HOST_CONTROL=false` in `.env` (plus removing the key and its authorized line yourself). Host
operations then refuse with a message naming the choice instead of the fault, and this box stops being
offered as a deploy target.

## Related

<Cards>
  <Card title="Installation" href="/docs/getting-started/installation#host-control-hardening" description="Where the channel comes from at install time, and which posture suits your box." />
  <Card title="Servers & SSH" href="/docs/troubleshooting/servers-ssh" description="The other SSH — connecting to a remote server you added yourself." />
  <Card title="Deployment & build failures" href="/docs/troubleshooting/deployments" description="A deploy that went red for a reason that isn't the host channel." />
  <Card title="Domains, DNS & SSL" href="/docs/troubleshooting/domains-ssl" description="The :80/:443 takeover this channel performs, and certificates that won't issue." />
</Cards>
