Skip to main content

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\sshssh_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\configHoster.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 this Include when 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 same Include line (or the host blocks directly) to the specific service account's own %USERPROFILE%\.ssh\config instead.

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's authorized_keys, per step 2 above.
  • Private half → pasted into the Add Docker Host form (Docker Hosts page in Hoster.App, or POST /api/docker-hosts) — Hoster.Api encrypts it at rest (IEncryptionService, same Data Protection key ring as every other site secret) and writes a live copy to Ssh:ManagedDirectory\keys\{id}.key for ssh/docker to 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.AppDocker HostsAdd 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:

  1. Assign it to the new Docker Host from its site detail page.
  2. Provision (or Apply Config / Redeploy if already provisioned).
  3. Confirm the container is actually running on the remote box, not locally — docker ps on the remote box's own console, not the local one.
  4. Confirm the certificate download (GET .../certificate) round-trips over scp.
  5. If publishing through a tunnel, confirm the ingress rule now points at the remote box's Address, and that whatever runs cloudflared can 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" — the Include from step 1 isn't being picked up; try the per-user ssh_config fallback mentioned there, and confirm with a plain ssh dockerhost-{id} whoami (using -F C:\ProgramData\Hoster\ssh\ssh_config explicitly) that the alias itself resolves at all before suspecting Docker's side of it.
  • Build succeeds but up fails with a bind-mount error — see "Bind mounts" above; the remote certs\<SiteId> path likely doesn't exist or RemoteRepoRoot is wrong.
  • Host key mismatch reported on test-connection — expected after rebuilding or re-imaging the remote box; remove and re-add the DockerHost to trust the new key (only after verifying it out-of-band).