Hosts
Hosts, roles, SSH connection settings, local execution mode, inventory-vs-deploy targets, and the unreachable-host policy.
A host is one deploy target, tagged with the roles it fills.
Hosts usually live in the per-stage file, with connection defaults in the shared ssh: block.
hosts:
- address: web1.example.com
roles: [ app, web ]
- address: db1.example.com
roles: [ db ]
user: dbadmin # override ssh.user for this host
port: 2222 # override ssh.port
identity_file: ~/.ssh/db_key
| Field | Description |
|---|---|
address | Hostname or IP (required unless local: true). |
roles | Roles this host fills, tasks/hooks target by role. |
user / port / identity_file | Per-host SSH overrides. |
identity_file_passphrase | Decrypts this host’s encrypted identity_file (templatable, redacted everywhere). |
local | Run on the operator’s machine via the local shell - see local mode. |
primary | Preferred host for single-host work: once: tasks and the deploy lock pick the first marked host among their candidates, else the first candidate. |
deploy | false keeps the host in inventory without deploying - see below. |
required | true makes its unreachability always fatal - see unreachable. |
Roles let one task hit a subset of the fleet: a task with roles: [db] runs only on db hosts.
The --roles / --host flags narrow any action further (see Usage -> Targeting).
SSH connection settings
Defaults applied to every host. A host may override user/port/identity_file.
ssh:
user: deploy
port: 22
identity_file: ~/.ssh/deploy_key # optional, joins the builtin agent (see below)
identity_file_passphrase: '{{ envSecret "DEPLOY_KEY_PASS" }}' # decrypts an encrypted identity_file
known_hosts_file: ~/.ssh/known_hosts # optional custom path
strict_host_key: true # verify host keys (default true)
accept_new: true # trust first-seen hosts, record their key (default true)
forward_agent: false # forward an ssh-agent to the host (for remote git auth)
forward_key: ~/.ssh/deploy # OR forward just this one key, in-memory
identities: # optional, feeds the builtin in-memory agent
worker_hosts:
path: ~/.ssh/id_worker # a key file
app_hosts:
content: '{{ envSecret "APP_DEPLOY_KEY" }}' # or the key PEM inline, e.g. from the env
passphrase: '{{ envSecret "APP_KEY_PASS" }}' # decrypts an encrypted key
all_keys:
path: ~/.ssh # or a directory: every key file in it is loaded
recursive: true # include subdirectories
bastion: # optional jump host (like OpenSSH ProxyJump)
address: bastion.example.com # required
user: jump # default: the operator's user
port: 22 # default: 22
identity_file: ~/.ssh/bastion_key # default: builtin agent / ssh-agent
identity_file_passphrase: '{{ envSecret "BASTION_KEY_PASS" }}'
- Auth: with no
identity_fileand noidentities, whoosh uses yourssh-agent(SSH_AUTH_SOCK). When either is set, whoosh builds its own in-memory agent from those keys and the system agent is not consulted - so CI and multi-key setups need nossh-agenton the operator machine. - Builtin agent (
identities): each entry is a key source, the name is just a label for logs and errors. Set exactly one ofpath(a key file, or a directory whose key files are all loaded -recursivedescends into subdirectories) orcontent(the key PEM inline). All keys are offered to every host, like a real agent, and per-hostidentity_fileoverrides keep working. An encrypted key needspassphrase- the field is a Go template rendered at load time, so it can come from the environment or an env file viaenvSecret. A directory scan skips non-key files and encrypted keys it cannot open (with a warning), while an explicit file or inline key that fails to load is a hard error.contentandpassphraseare always redacted in theconfigdump,{{.config}}, and logs. - Encrypted identity files:
identity_file_passphrasedecrypts an encryptedidentity_file, at thessh:level or per host. Like the identities’passphrase, it is a Go template rendered at load time (so it can come fromenvSecret) and always redacted. A host inherits the global passphrase only together with the globalidentity_file- a host that sets its ownidentity_file(even repeating the global path) sets its own passphrase, so a wrong global passphrase is never tried against a different key. Hosts discovered by an inventory plugin arrive after load-time rendering: a passphrase the plugin sets is used verbatim, only the inherited global one is templated. - Host keys are verified against
~/.ssh/known_hostsby default, OpenSSHaccept-newstyle: a host seen for the first time is trusted and its key appended to the known_hosts file (created, along with its directory, when missing), while a changed key fails - so fresh environments (containers, CI) work out of the box without losing protection against key swaps. Setaccept_new: falseto require every host key to already be present (strictest; pre-populate withssh-keyscan),strict_host_key: falseto skip verification entirely, orknown_hosts_filefor a custom path. A single task can override this with its ownstrict_host_key: false(see Tasks) - for ephemeral hosts whose key is legitimately unknown (e.g. ASG instances from one AMI), without loosening the rest of the deploy. - Agent forwarding lets the remote
gitclone/fetch authenticate with your credentials (e.g. for a private repo). Withforward_agent: true, the builtin agent is forwarded when it is active, otherwise your local ssh-agent (SSH_AUTH_SOCK) - so forwarding withidentitiesneeds no system agent either.forward_keyforwards a single unencrypted key in memory (never written to the host) and takes precedence over both. Forwarding is best-effort: a host withAllowAgentForwarding nowill still run, but its git won’t see your keys. - Liveness: a new connection times out after ~15s. On an established connection whoosh sends a keepalive every 10s and drops a silent host after 3 misses (~30s) so a dead host fails fast instead of hanging.
Bastion (jump host)
Hosts in a private network are often reachable only through a bastion. ssh.bastion routes every
SSH connection through one jump host, like OpenSSH ProxyJump (single hop):
ssh:
user: deploy
bastion:
address: bastion.example.com
user: jump
identity_file: ~/.ssh/bastion_key
hosts:
- address: 10.0.1.10 # private, reached through the bastion
roles: [ app ]
- One shared connection: whoosh opens the bastion connection once, lazily on the first host dial, and every host gets its own tunneled channel over it. The connection is closed with the rest at the end of the run.
- Auth: the bastion authenticates like any host - its own
identity_file(with an optional templatable, always-redactedidentity_file_passphrase), else the builtin agent (identities), else yourssh-agent. It does not inheritssh.user/ssh.identity_file- those are the app hosts’ credentials. - Host keys: the bastion’s key is verified with the same
strict_host_key/known_hosts_file/accept_newsettings as the targets. - No agent forwarding to the bastion:
forward_agent/forward_keyapply to the app hosts only, matching OpenSSH-Jsemantics. - Local hosts bypass the bastion (they never dial SSH). Hosts discovered by an inventory plugin
(e.g. private EC2 instances from
aws:ec2:inventory) are tunneled like any other host. - A stage can replace a bastion declared in the shared config but not unset it - declare
bastionin the stage files that need it. - If the bastion is unreachable, every host fails with a
bastion <host>:prefixed error (underon_unreachable: skipthat means every host is skipped - the log makes the cause obvious). - The AWS plugin’s
credentials_from_hostopens its own connection and is not tunneled.
Local execution mode
Mark a host local: true to run the entire lifecycle on the current machine through the local shell - no SSH, keys,
or known_hosts needed. Useful for deploying on the box itself, or for development.
Everything else (releases, symlinks, current, rollback, tasks, hooks) is identical.
# deploy/local.yml
hosts:
- address: localhost
local: true
roles: [ app, web, db ]
Local and remote hosts can coexist in one stage, each running over its own transport.
Inventory vs deploy targets
By default, every host is a deployment target.
Set deploy: false to keep a host in the inventory - visible in config and the deploy:hosts table - without
deploying to it. Such hosts are excluded from the lifecycle, tasks, hooks, and ad-hoc run.
hosts:
- address: web1.example.com
roles: [ app, web ]
- address: bastion.example.com
roles: [ ops ]
deploy: false # listed, but never deployed to
This pairs with dynamic inventory: a plugin can discover a fleet and flag which hosts
to deploy to (see aws:ec2:inventory deploy_tag).
Two task flags change which hosts a task targets:
non_deploy: true- only thedeploy:falsehosts (inverts the default). For acting on hosts you don’t deploy to, e.g. healthchecking ASG instances booted from a baked AMI.all_hosts: true- every host, ignoring thedeployflag. For a task that should hit the whole fleet (e.g. collecting disk usage). Wins overnon_deploy.
Roles and --roles/--host still narrow within the chosen set.
tasks:
asg-healthcheck:
non_deploy: true # only deploy:false hosts
strict_host_key: false # ASG hosts share a key / rotate IPs -> skip known_hosts
scripts:
- path: healthcheck.sh
disk-usage:
all_hosts: true # every host in the stage
cmds: [ "df -h /" ]
A task’s strict_host_key (true/false) overrides the stage’s ssh.strict_host_key for just that task’s
connections - set false for ephemeral hosts whose key isn’t in known_hosts (the override applies when the
connection is first opened in a run).
Inventory is captured at process start
Both flags operate on the inventory captured at process start.
A fresh run re-fetches dynamic inventory, so instances created during a deployment (e.g. by an ASG refresh) only appear
on
the next invocation, not to a hook within the same deploy.
Run these as their own post-deploy step (whoosh prod asg-healthcheck), not as a deploy hook.
Unreachable hosts
By default, a host that becomes unreachable mid-deploy aborts the whole run.
Set on_unreachable: skip to drop the unreachable host and finish on the survivors.
on_unreachable: skip # abort (default) | skip
hosts:
- address: db1.example.com
roles: [ db ]
required: true # never skip this one - its loss always aborts
- address: web1.example.com
roles: [ app, web ] # skippable under `skip`
abort(default) - any unreachable host fails the deploy.skip- an unreachable host is dropped (from remaining phases and hook tasks), the deploy completes on the rest, skipped hosts are logged, and the command exits non-zero so CI notices.required: truekeeps a critical host from being skipped. The lock-holding primary host is always implicitly required.- The policy applies to unreachable hosts only (a failed dial or a connection lost mid-command).
A command that runs and exits non-zero (e.g. a failed migration) always aborts - use a task’s
continue_on_errorto soften those.