Remote Docker Host setup
How to add a second Windows box as a Docker target for Sites (DockerHosts in
Hoster.Db), so a site's docker compose build/up/down run there instead of on
the machine hosting Hoster.Api. See ARCHITECTURE.md for how the rest of the app
works — this doc only covers the remote-host mechanics
(Hoster.Api/Docker/SshDockerHostService.cs).
A site with no DockerHost assigned is completely unaffected by any of this — it
keeps running on the local machine exactly as before this feature existed. Everything
below is only needed the first time you want to target a different box.
How it works, briefly
Hoster.Api runs the same docker CLI it always has, but for a site with a
DockerHost assigned it adds -H ssh://dockerhost-{id} to every compose command. That
alias resolves through an SSH config fragment Hoster.Api generates and keeps in sync
under Ssh:ManagedDirectory (default C:\ProgramData\Hoster\ssh —
ssh_config/known_hosts/keys\{id}.key). Docker's own SSH transport streams the
build context to the remote engine automatically — no source code needs to live on the
remote box. The one thing that does need to exist there is the certs\<SiteId>
directory that the compose file bind-mounts (Hoster.Api creates it over SSH before
up, and fetches the generated cert back the same way) — see "Bind mounts" below.
1. One-time setup on the machine running Hoster.Api
This is only needed once, regardless of how many remote DockerHosts you add later.
OpenSSH Client — usually already present on Windows 10/11 and Server 2019+; confirm:
Get-WindowsCapability -Online -Name OpenSSH.Client*
# If NotPresent:
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
ssh, scp, and ssh-keyscan all need to resolve on PATH for the account
Hoster.Api runs as — they're invoked by SshDockerHostService.cs the same way
docker/robocopy already are.
Point the system SSH config at Hoster's generated one. Hoster.Api writes its own
managed fragment (C:\ProgramData\Hoster\ssh\ssh_config by default) but never touches
the real one — add an Include line so plain ssh/docker -H ssh://... calls pick it
up:
$real = "$env:ProgramData\ssh\ssh_config"
if (-not (Test-Path $real)) { New-Item -ItemType File -Path $real -Force | Out-Null }
$includeLine = 'Include C:\ProgramData\Hoster\ssh\ssh_config'
if (-not (Select-String -Path $real -Pattern ([regex]::Escape($includeLine)) -Quiet)) {
# Include directives only apply to lines below them — must go first.
(Get-Content $real -Raw) | Set-Content -Path "$real.tmp"
Set-Content -Path $real -Value ($includeLine + "`n" + (Get-Content "$real.tmp" -Raw))
Remove-Item "$real.tmp"
}
Use C:\ProgramData\ssh\ssh_config (the system-wide file), not a per-user
%USERPROFILE%\.ssh\config — Hoster.Api normally runs as a service account without
an interactive profile.
Unverified assumption, confirm on your setup: whether
docker -H ssh://<alias>actually resolves an alias through thisIncludewhen invoked as a plain child process (not an interactive shell) hasn't been confirmed against the exact Docker/OpenSSH versions in use here. Test it (see step 4) before relying on it for a real site. If it doesn't resolve, the fallback is adding the sameIncludeline (or the host blocks directly) to the specific service account's own%USERPROFILE%\.ssh\configinstead.
2. One-time setup on each remote Windows box
Docker, in Windows containers mode, same as the primary host — same Windows build compatibility rules apply (a container base image needs a host OS build it's compatible with).
OpenSSH Server:
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
Start-Service sshd
Set-Service -Name sshd -StartupType Automatic
Get-NetFirewallRule -Name *ssh* | Select Name, Enabled # confirm inbound 22 is allowed
A dedicated local user for Hoster to log in as — don't use an existing admin
account. Add it to docker-users (Docker Desktop only allows Engine access to
Administrators or docker-users members) but leave it out of Administrators:
New-LocalUser -Name "hoster-svc" -NoPassword -AccountNeverExpires
Add-LocalGroupMember -Group "docker-users" -Member "hoster-svc"
Staying out of Administrators matters here for more than least-privilege: Windows
OpenSSH routes admin accounts' authorized keys through a special shared file
(administrators_authorized_keys with its own ACL requirements) instead of the normal
per-user one — skip that whole complication by keeping this account a plain member of
docker-users.
Authorize the key — generate the pair first (step 3 below covers the exact command and where the private half goes), then put the public half here:
$authKeys = "C:\Users\hoster-svc\.ssh\authorized_keys"
New-Item -ItemType Directory -Force -Path "C:\Users\hoster-svc\.ssh" | Out-Null
Set-Content -Path $authKeys -Value "<paste the .pub file's contents>"
icacls $authKeys /inheritance:r /grant:r "hoster-svc:F" /grant:r "SYSTEM:F" /grant:r "Administrators:F"
Directory layout — the compose file's ./certs/<SiteId> bind mount needs a real
path on this box's own disk (see "Bind mounts" below). Hoster.Api creates the
certs\<SiteId> subfolder itself before up, but the parent needs to exist and match
either the same path as the local Docker:RepoRoot, or whatever you set
DockerHost.RemoteRepoRoot to when adding this host in Hoster.
3. Generate and register the SSH keypair
From any machine with ssh-keygen (the local Hoster.Api box is fine):
ssh-keygen -t ed25519 -f .\hoster-remote1 -N '""'
This produces hoster-remote1 (private) and hoster-remote1.pub (public). Despite the
"SSH Private Key (PEM)" label on the Add Docker Host form, whatever you paste is
written to the key file verbatim — both OpenSSH's default key format (what the command
above produces) and PEM (ssh-keygen -m PEM) work fine, since it's the system ssh
client reading it, not anything in Hoster.Api parsing the format itself.
- Public half (
.pub) → the remote box'sauthorized_keys, per step 2 above. - Private half → pasted into the Add Docker Host form (Docker Hosts page in
Hoster.App, orPOST /api/docker-hosts) —Hoster.Apiencrypts it at rest (IEncryptionService, same Data Protection key ring as every other site secret) and writes a live copy toSsh:ManagedDirectory\keys\{id}.keyforssh/dockerto use.
Delete the plaintext files from wherever ssh-keygen wrote them once both halves are
in place — nothing needs to keep them around after this.
4. Add the host and verify
In Hoster.App → Docker Hosts → Add Docker host: Name (label only), Address
(the box's reachable IP/hostname — this also becomes the Cloudflare tunnel origin for
any site assigned here, replacing the old per-site "Local Host/IP" value), SSH
Username (hoster-svc), SSH Port (22 unless changed), the private key from step 3, and
RemoteRepoRoot only if that box's checkout lives at a different path than the local
Docker:RepoRoot.
Click Test connection. First success pins the host's SSH fingerprint
(trust-on-first-use) — verify it out-of-band once (e.g. run ssh-keygen -lf against
the known_hosts entry on the remote box's own console) rather than blindly trusting
whatever the network handed back. A later mismatch (host rebuilt, key rotated without
updating Hoster) is reported, not silently re-trusted — remove and re-add the host to
accept a new key on purpose.
Then, on a throwaway test site (not a real one) before trusting this for anything that matters:
- Assign it to the new Docker Host from its site detail page.
- Provision (or Apply Config / Redeploy if already provisioned).
- Confirm the container is actually running on the remote box, not locally —
docker pson the remote box's own console, not the local one. - Confirm the certificate download (
GET .../certificate) round-trips overscp. - If publishing through a tunnel, confirm the ingress rule now points at the remote
box's
Address, and that whatever runscloudflaredcan actually reach that address/port over the network (firewall + routing, same as it always needed to reach a local port — just now across the network instead of over loopback).
Only after that passes should you point a real site at this host.
Bind mounts — the one thing that doesn't just work over SSH
Docker Compose resolves a relative bind-mount source (./certs/<SiteId>) against the
client's working directory string, then hands that literal path to whichever engine
is on the other end of -H. For a remote host, the remote engine looks for that
path on its own disk — Compose doesn't copy anything there for you. That's why the
remote box needs a matching certs\ directory structure (step 2) and why
Hoster.Api creates certs\<SiteId> over SSH before every up rather than relying on
Windows' auto-create-missing-bind-source behavior (which doesn't exist on Windows
anyway — see the comment in DockerProvisioningService.ComposeUpAsync). If you ever
change what the compose file mounts, re-check this assumption.
Troubleshooting
- "Could not create hard link" / SSH auth failures — confirm
authorized_keys' ACL exactly matches step 2 (OpenSSH silently ignores a key file with wrong permissions rather than erroring clearly). docker -H ssh://...hangs or errors "unable to resolve host" — theIncludefrom step 1 isn't being picked up; try the per-userssh_configfallback mentioned there, and confirm with a plainssh dockerhost-{id} whoami(using-F C:\ProgramData\Hoster\ssh\ssh_configexplicitly) that the alias itself resolves at all before suspecting Docker's side of it.- Build succeeds but
upfails with a bind-mount error — see "Bind mounts" above; the remotecerts\<SiteId>path likely doesn't exist orRemoteRepoRootis wrong. - Host key mismatch reported on test-connection — expected after rebuilding or
re-imaging the remote box; remove and re-add the
DockerHostto trust the new key (only after verifying it out-of-band).