Remote Docker on a Hetzner VM
Run Tasuku agent containers on an inexpensive, dedicated Docker host without exposing the Docker API to the internet.
Tasuku can keep its server, worker, and Postgres services on one host while running agent containers on a separate virtual machine. The VM is a remote Docker host; it does not become a container. The worker connects to its Docker Engine endpoint.
The recommended endpoint is:
tcp://10.20.0.3:2376Port 2376 must bind only to a private interface, require mutual TLS (mTLS), and accept traffic only from the Tasuku worker host. Never publish Docker's unauthenticated port 2375.
Docker access is root-equivalent
A Docker client certificate can create privileged containers, mount host paths, and control the VM. Protect it like a root password. A private network and mTLS protect the connection; they do not make the remote daemon safe for untrusted clients.
Operator-controlled Docker configuration
Docker host selection is a worker setting. Configure DOCKER_HOST on the worker; it is intentionally not exposed as a repository setting.
Recommended topology
Tasuku host Hetzner Docker VM
server + Postgres Docker Engine
worker container agent containers
| ^
+--- private network + mTLS :2376 --------+
Public ingress to Docker VM: SSH from an allowlisted admin IP only
Public ingress to port 2376: noneUse a dedicated VM for agent execution. Do not colocate a database, customer workload, or another tenant's containers on it. Tasuku's container restrictions reduce the impact of an agent process, but Docker Engine remains a privileged trust boundary.
Why Hetzner and which size
Hetzner is a practical default for a low-cost, single-host deployment. Its cost-optimized CX33 is x86-64 with 4 shared vCPUs, 8 GB RAM, and 80 GB disk. Hetzner's price published for new EU orders from June 15, 2026 is EUR 8.49/month excluding VAT and IPv4. The smaller CX23 costs EUR 5.49/month but has only 4 GB RAM, equal to Tasuku's default per-agent memory limit, so it leaves too little host headroom.
Start with CX33 for one concurrent agent. Measure CPU, memory, and disk use before increasing concurrency. Use a dedicated-vCPU plan when latency or sustained CPU performance matters more than minimum price. Prefer x86-64 unless every tool in the agent image has been verified on Arm.
Sources: Hetzner cost-optimized server specifications, June 2026 Hetzner prices, and Hetzner shared versus dedicated resources.
1. Provision the VM and private network
This example uses these addresses:
| Component | Private address |
|---|---|
| Tasuku worker host | 10.20.0.2 |
| Remote Docker VM | 10.20.0.3 |
- Create a Hetzner Cloud Network such as
10.20.0.0/24and attach both servers. Hetzner Networks provide private layer-3 addresses, but traffic is not automatically encrypted, which is why this guide still requires mTLS. - Create an Ubuntu 24.04
CX33in the same network and select an SSH key during creation. - Attach a public IP only when needed for administration and outbound package/image access.
- Apply a Hetzner Cloud Firewall that allows TCP
22only from the administrator's fixed public IP. Do not add public rules for2375or2376; unspecified inbound traffic is dropped. - Use SSH keys only. Create a named sudo administrator and disable password authentication and direct root login after testing a second session.
Hetzner Cloud Firewalls do not filter traffic inside a private Cloud Network. The host firewall configured later is therefore required for port 2376. See Hetzner Networks, network encryption guidance, and Cloud Firewall behavior.
If the Tasuku host is outside Hetzner, put both hosts on a WireGuard or equivalent private network and bind Docker to that interface. If that is impossible, allowlist only the Tasuku host's static public IP at both the provider and host firewalls and keep mTLS enabled.
2. Install Docker Engine
On the remote VM, install Docker from Docker's signed apt repository. Docker recommends the repository for production installation; its convenience script is intended for testing and development.
sudo apt update
sudo apt install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
. /etc/os-release
printf '%s\n' \
'Types: deb' \
'URIs: https://download.docker.com/linux/ubuntu' \
"Suites: ${UBUNTU_CODENAME:-$VERSION_CODENAME}" \
'Components: stable' \
"Architectures: $(dpkg --print-architecture)" \
'Signed-By: /etc/apt/keyrings/docker.asc' \
| sudo tee /etc/apt/sources.list.d/docker.sources
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo systemctl enable --now docker
sudo docker run --rm hello-worldKeep the VM patched and follow Docker's Ubuntu installation instructions when upgrading Docker.
3. Create an mTLS certificate set
Follow Docker's daemon socket protection guide. Generate the CA and certificates on a trusted administrator machine, not inside the repository. Replace 10.20.0.3 if the VM uses another private IP.
umask 077
mkdir docker-pki
cd docker-pki
DOCKER_VM_IP=10.20.0.3
openssl genrsa -aes256 -out ca-key.pem 4096
openssl req -new -x509 -days 365 -sha256 \
-key ca-key.pem -out ca.pem -subj '/CN=tasuku-docker-ca'
openssl genrsa -out server-key.pem 4096
openssl req -new -sha256 -key server-key.pem \
-out server.csr -subj '/CN=tasuku-docker'
printf 'subjectAltName = IP:%s\nextendedKeyUsage = serverAuth\n' "$DOCKER_VM_IP" > server-ext.cnf
openssl x509 -req -days 365 -sha256 -in server.csr \
-CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out server-cert.pem -extfile server-ext.cnf
openssl genrsa -out key.pem 4096
openssl req -new -sha256 -key key.pem \
-out client.csr -subj '/CN=tasuku-worker'
printf 'extendedKeyUsage = clientAuth\n' > client-ext.cnf
openssl x509 -req -days 365 -sha256 -in client.csr \
-CA ca.pem -CAkey ca-key.pem -CAcreateserial \
-out cert.pem -extfile client-ext.cnf
chmod 0400 ca-key.pem server-key.pem key.pem
chmod 0444 ca.pem server-cert.pem cert.pemDistribute only these files:
| Destination | Files |
|---|---|
| Remote Docker VM | ca.pem, server-cert.pem, server-key.pem |
| Tasuku host | ca.pem, cert.pem, key.pem |
Keep ca-key.pem and the CA serial file offline for rotation. Do not copy the CA private key to either server. Delete the temporary CSR and extension files after verifying the deployment. Set an expiry alert and rotate the server and client certificates before 365 days.
4. Bind Docker to the private interface
On the remote Docker VM, install the three server-side files:
sudo install -d -m 0700 /etc/docker/tls
sudo install -m 0444 ca.pem server-cert.pem /etc/docker/tls/
sudo install -m 0400 server-key.pem /etc/docker/tls/Run sudo systemctl edit docker.service and add this override. Replace the private IP when necessary.
[Service]
ExecStart=
ExecStart=/usr/bin/dockerd -H fd:// -H tcp://10.20.0.3:2376 --containerd=/run/containerd/containerd.sock --tlsverify --tlscacert=/etc/docker/tls/ca.pem --tlscert=/etc/docker/tls/server-cert.pem --tlskey=/etc/docker/tls/server-key.pemDo not also set hosts in /etc/docker/daemon.json; Docker documents that configuring listeners in both systemd and daemon.json causes a startup conflict. Reload and verify the listener:
sudo systemctl daemon-reload
sudo systemctl restart docker.service
sudo systemctl --no-pager --full status docker.service
sudo ss -lntp | grep ':2376'The output must show 10.20.0.3:2376, not 0.0.0.0:2376. See Docker remote daemon access.
5. Add a host firewall
Set the variables to real addresses before running these commands. Add the SSH rule before enabling UFW to avoid locking yourself out.
ADMIN_PUBLIC_IP=203.0.113.10
TASUKU_PRIVATE_IP=10.20.0.2
sudo apt install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from "$ADMIN_PUBLIC_IP" to any port 22 proto tcp
sudo ufw allow from "$TASUKU_PRIVATE_IP" to any port 2376 proto tcp
sudo ufw enable
sudo ufw status verboseKeep Tasuku agent containers free of published host ports. Review host firewall behavior after every Docker upgrade.
6. Install the client certificate on the Tasuku host
Copy ca.pem, cert.pem, and key.pem to the Tasuku host, then build the worker image and give only its numeric user access to the directory:
docker compose build worker
WORKER_UID="$(docker compose run --rm --no-deps --entrypoint id worker -u)"
WORKER_GID="$(docker compose run --rm --no-deps --entrypoint id worker -g)"
sudo install -d -m 0700 /etc/tasuku/docker-client
sudo install -m 0444 ca.pem cert.pem /etc/tasuku/docker-client/
sudo install -m 0400 key.pem /etc/tasuku/docker-client/
sudo chown -R "$WORKER_UID:$WORKER_GID" /etc/tasuku/docker-clientTest mTLS from the Tasuku host. This verifies the certificate chain, server identity, firewall, and Docker API before Tasuku is involved:
sudo env \
DOCKER_HOST=tcp://10.20.0.3:2376 \
DOCKER_TLS_VERIFY=1 \
DOCKER_CERT_PATH=/etc/tasuku/docker-client \
docker versionBoth client and server sections must be present.
7. Build the agent image on the remote daemon
The tasuku-agent-runtime:local image built by the default Compose topology exists only on the local Docker host. Build it explicitly on the remote daemon from the Tasuku repository root:
sudo env \
DOCKER_HOST=tcp://10.20.0.3:2376 \
DOCKER_TLS_VERIFY=1 \
DOCKER_CERT_PATH=/etc/tasuku/docker-client \
docker build --file docker/Dockerfile.agent --tag tasuku-agent-runtime:local .
sudo env \
DOCKER_HOST=tcp://10.20.0.3:2376 \
DOCKER_TLS_VERIFY=1 \
DOCKER_CERT_PATH=/etc/tasuku/docker-client \
docker image inspect tasuku-agent-runtime:localRepeat this step whenever the runtime Dockerfile or agent toolchain changes. A private registry is a better distribution path when several Docker VMs must run the same immutable image.
8. Point the Tasuku worker at the endpoint
Require Docker Compose 2.24.4 or later for the !override merge tag. Create docker-compose.remote-docker.yml beside docker-compose.yml:
services:
worker:
environment:
DOCKER_HOST: ${TASUKU_DOCKER_HOST:?TASUKU_DOCKER_HOST is required}
DOCKER_TLS_VERIFY: "1"
DOCKER_CERT_PATH: /run/docker-tls
volumes: !override
- type: bind
source: /etc/tasuku/docker-client
target: /run/docker-tls
read_only: true
bind:
create_host_path: false
group_add: !reset []
depends_on: !override
migrate:
condition: service_completed_successfully
agent-runtime:
profiles: ["local-docker"]This removes the local Docker socket mount, removes its supplemental group, and stops the local agent-runtime build service from being part of the remote-Docker topology. The merge behavior is defined in Docker's Compose override documentation.
Set the endpoint outside version control, render the merged model, and inspect the worker before starting it:
export TASUKU_DOCKER_HOST=tcp://10.20.0.3:2376
docker compose \
-f docker-compose.yml \
-f docker-compose.remote-docker.yml \
configThe rendered worker must have /run/docker-tls, must not have /var/run/docker.sock, and must contain all three Docker TLS environment variables. Then start Tasuku:
docker compose \
-f docker-compose.yml \
-f docker-compose.remote-docker.yml \
up --build -d
docker compose \
-f docker-compose.yml \
-f docker-compose.remote-docker.yml \
exec worker docker versionIn Tasuku, select the Docker sandbox provider and set the runtime image to tasuku-agent-runtime:local. Keep CPU, memory, and PID limits appropriate for the VM. The worker environment is the authoritative Docker endpoint.
9. Verify end to end
- Confirm
docker versionsucceeds inside the worker container. - Trigger a small Tasuku workflow against a test repository.
- Query the remote engine and confirm a
tasuku-*container appears there, not on the Tasuku host. - Confirm the container is removed after completion.
- Confirm public scans of the VM cannot reach ports
2375or2376. - Reboot the remote VM and verify Docker, the firewall, and the worker connection recover.
Operational controls
- Treat the VM as disposable compute. Keep the runtime image reproducible and rebuild the VM rather than preserving manual state.
- Patch Ubuntu and Docker regularly. Test client/server Docker API compatibility during upgrades.
- Monitor disk, memory, load, Docker daemon restarts, certificate expiry, and failed connections.
- Review stopped containers, unused images, build cache, and volumes before pruning. Never schedule an indiscriminate
docker system prune --volumes. - Keep one remote Docker host per security boundary. mTLS authenticates the worker; it does not provide tenant-level Docker authorization.
- Keep the client key out of Git,
.env, Postgres, logs, and backups that do not have root-secret protection. - Apply provider-level outbound controls if your deployment requires network destination restrictions; Tasuku does not maintain an outbound destination allowlist.
Troubleshooting
| Symptom | Check |
|---|---|
connection refused | Private route, ss listener, UFW source IP, and Docker service logs. |
x509: certificate is valid for ... | Reissue the server certificate with the exact private IP or DNS name in its SAN. |
certificate signed by unknown authority | Verify the Tasuku host and Docker daemon use certificates from the same CA. |
no such image: tasuku-agent-runtime:local | Build or pull the image on the remote daemon, not only on the Tasuku host. |
permission denied reading key.pem | Recheck the numeric worker UID/GID and /etc/tasuku/docker-client ownership. |
| Worker still uses the local socket | Inspect the merged Compose model and confirm the !override file was supplied to every command. |
| Agent container cannot start | Verify worker-level DOCKER_HOST connectivity and that the runtime image exists on the remote host. |
EC2 equivalent
The same design works on EC2: put the Tasuku worker and Docker instance in the same VPC, bind Docker to the instance's private address, and allow TCP 2376 only from the Tasuku worker's security group. Keep mTLS because network rules restrict reachability but do not protect a stolen or misrouted Docker client credential. AWS documents that private IPv4 addresses are not internet-reachable and that a security group can use another security group as its source.
Use EC2 when Tasuku already operates inside an AWS VPC or depends on AWS operational controls. For a small standalone deployment, Hetzner is usually the simpler, lower-cost starting point.