Dockhand
A CLI tool for managing Docker containers on remote machines. Build, run, and manage Docker containers with a single command from your local machine.
Why this project?
Managing Docker workloads across multiple machines is painful: keeping code in sync, remembering which version runs where, handling build/run/debug cycles remotely.
dockhand solves this by letting you manage everything from your laptop — code syncs automatically, builds happen on the remote, and results can be downloaded back. No more juggling multiple git repos or SSH sessions.
This project is heavily inspired by the awesome work from @ChrisFugl's DTU-HPC-CLI.
Features
- Fast Iteration: Code is mounted into the container at runtime — no rebuild needed when your code changes
- Job Queue: All workloads are submitted through task spooler so jobs run in order without competing for resources
- Slot Reservations: Declare how many CPU slots a job needs so heavy jobs don't block each other
- Remote Docker Management: Build and run Docker containers on a remote machine via SSH
- Local or Remote: Automatically detect localhost vs remote, or explicitly configure
- Port Forwarding: Establish SSH tunnels to access container ports locally
- File Syncing: Automatically sync your local code to the remote before running
- Volume Management: Mount data directories into containers and download results back
- Stable Job IDs: Local job IDs increment independently of the host, so IDs are unambiguous across machines
- Quick Resubmit: Easily rerun previous jobs with the same or different parameters
Requirements
Local machine
- Python 3.10+
- git — used for branch tracking and code sync
Docker host (remote or local)
- Docker — container runtime
- NVIDIA Container Toolkit (
nvidia-docker) — required only if using GPUs (--gpus) - task spooler (
tsp) — job queue daemon
Installing task spooler on Ubuntu/Debian:
sudo apt-get install task-spooler
On other systems the binary may be called ts instead of tsp — check your package manager.
Configuring the total slot count (optional, recommended for multi-user setups):
# Set the number of available slots to match CPU cores (run once on the host)
tsp -S 16
Jobs default to 1 slot each. Use --slots to reserve more (see Slot Reservations).
Installation
From source with uv (recommended):
curl -LsSf https://astral.sh/uv/install.sh | sh
Then clone and sync:
git clone https://github.com/nicholasphansen/dockhand.git
cd dockhand
uv sync
uv run dockhand --help
Or with pip:
pip install dockhand
Usage
All commands work with the same .dockhand.json configuration file. See Configuration below.
Available Commands
| Command | Description |
|---|---|
submit |
Build the image and queue a container run |
run |
Queue a container run from an already-built image |
install |
Build the Docker image without running it |
jobs |
List active (running/queued) jobs, with start/end/duration — use --all for finished jobs too, or --queue for the host's whole task-spooler queue across all projects |
logs |
Show logs from a job, preceded by a running/finished duration line — use --follow/-f to stream live |
stop |
Stop a running job |
remove |
Remove a queued job before it starts |
urgent |
Promote a queued job to the front of the queue |
prune |
Remove baked images no longer used by an active job |
history |
Show history of all past runs |
volumes |
List the full container filesystem as a tree |
download |
Download a file from a docker volume |
resubmit |
Resubmit a previous job with optional overrides |
tunnel |
Forward container ports to localhost via SSH tunnel |
All commands that accept a job ID default to the most recent job if no ID is given.
Typical workflow
# 1. Build the image once (or when dependencies change)
dockhand install
# 2. Iterate freely — submit syncs code and queues a run, no rebuild needed
dockhand submit 'python train.py --epochs 10'
dockhand submit 'python train.py --epochs 10' # code changed? just submit again
Examples
# Sync code and queue a run
dockhand submit 'python train.py --epochs 10'
# Reserve 4 CPU slots for a parallel job
dockhand submit --slots 4 'python train.py --workers 4'
# Queue a run with GPUs and port mappings
dockhand submit --gpus all -p 6006:6006 'python train.py'
# Queue a run without syncing (code already up to date)
dockhand submit --no-sync 'python train.py'
# Queue a run from an already-built image, skip sync
dockhand run 'python train.py --epochs 20'
# Build image (run once, or when dependencies change)
dockhand install
# Show full build output
dockhand install -v
# Build a specific Dockerfile
dockhand install --dockerfile Dockerfile.prod
# Check active jobs
dockhand jobs
# Check all jobs including finished
dockhand jobs --all
# Show the host's whole task-spooler queue, including other projects' jobs
dockhand jobs --queue
# Stream logs from the last job
dockhand logs --follow
# Show the last 50 lines from job #3
dockhand logs 3 --n 50
# Stop the last running job
dockhand stop
# Remove a queued job before it starts
dockhand remove 4
# Promote job #5 to the front of the queue
dockhand urgent 5
# Forward container ports to localhost
dockhand tunnel
# See all past runs
dockhand history
# Browse mounted volumes as a tree
dockhand volumes --depth 2
# Download results
dockhand download results/model.pth
# Resubmit the latest job with different GPUs
dockhand resubmit --gpus 2
Note: If you've installed dockhand globally, you can omit uv run.
jobs shows Started/Ended/Duration for each job, and logs prints a one-line
running for 5m30s / finished in 12m45s header before the log output. Start times come
from the container's docker inspect and finished durations from task spooler itself, so
they're exact no matter when you check (older jobs from before container naming fall back
to the time they were first observed). A running job's elapsed time is measured against the
host's clock, so clock skew between your machine and the host doesn't distort it (keep the
host's clock NTP-synced, though — Started/Ended are shown in the host's time).
jobs --queue lists every job in the host's task-spooler queue, not just this project's:
| Column | Meaning |
|---|---|
ID |
dockhand job ID in the submitting project — what logs/stop/urgent take there (- for jobs submitted before dockhand named its containers) |
Project |
Image the job runs (the project's imagename) |
Status / Started / Duration |
From task spooler (tsp -l, tsp -i), measured on the host's clock |
TS ID |
Task spooler's own job ID, for use with tsp directly |
Running and queued jobs come first (queued in queue order), then the rest newest-started first.
Resubmitting a job that ran in bake mode reruns the exact
image it originally built — recorded per job — rather than rebuilding from current code, so a
past run reproduces faithfully. (Overriding --imagename opts out and uses the new image.)
Slot Reservations
When multiple users share a Docker host, jobs may need different amounts of CPU resources. The --slots option maps to tsp -N <n>, which tells task spooler how many of the host's total slots a job should consume before it is allowed to start.
# Lightweight job — uses 1 slot (default)
dockhand run 'python eval.py'
# Parallel job — blocks 8 slots while running
dockhand run --slots 8 'python train.py --workers 8'
The total number of slots available on the host is set with tsp -S <n> (run once by the system admin). A job only starts when enough free slots are available, so heavy jobs naturally queue behind each other without manual coordination.
The default slot count can also be set in .dockhand.json so it applies to every submission without needing the flag:
{
"queue": {
"slots": 4
}
}
Volumes
The volumes command lists the container filesystem as a unified tree rooted at containerworkdir. It searches the code mount (project root → containerworkdir) and all configured data volumes on the host directly — no running container required. Files appear at their container paths.
# Show container filesystem (default depth: 5 levels)
dockhand volumes
# Expand to a specific depth; folders at the limit show as collapsed with ...
dockhand volumes --depth 3
# Show filesystem for a specific past job
dockhand volumes 42
# Same via download --list
dockhand download --list --depth 2
The download command resolves any workdir-relative path (as shown by volumes) back to its host location and downloads it via rsync. Directory downloads preserve the full path structure locally.
# Download a file
dockhand download results/model.pth
# Download a directory (trailing slash) — files land in reports/figures/ locally
dockhand download reports/figures/
Configuration
Create a .dockhand.json file in your project root.
Minimal Configuration
{
"docker": {
"dockerfile": "Dockerfile",
"imagename": "my-image",
"volumes": []
}
}
SSH Configuration
To run Docker commands on a remote machine:
{
"ssh": {
"user": "your_username",
"identityfile": "/path/to/private/key",
"hostname": "remote.example.com"
},
"docker": { "..." }
}
Hostname detection:
- No
sshconfig, or hostname resolves to127.0.0.1→ runs locally - Otherwise → connects via SSH
Docker Configuration
{
"sync": true,
"ssh": { "..." },
"queue": {
"enabled": true,
"slots": 1
},
"docker": {
"dockerfile": "Dockerfile",
"imagename": "my-image",
"volumes": [
{
"hostpath": "/local/data",
"containerpath": "/data",
"permissions": "rw"
}
],
"ports": ["8080:80"],
"gpus": "all",
"containerworkdir": "/",
"preserve_paths": [".venv"]
}
}
Top-level options:
| Option | Description | Default |
|---|---|---|
sync |
Rsync local code to remote before building | true |
Queue sub-config options:
| Option | Description | Default |
|---|---|---|
enabled |
Submit jobs through the task-spooler queue | false |
tool |
Queue backend | task_spooler |
slots |
Default queue slots to reserve per job (overridden by --slots) |
1 |
When enabled is true, jobs are queued with tsp and run in submission order. When
false, there is no queue — each submit starts immediately as a detached container
(docker run -d --name dockhand-<id>). logs, stop, jobs, and remove work the same
in both modes; they transparently use tsp or docker per the job's recorded transport.
--slots and --urgent are queue-only and are ignored (with a warning) when the queue is off.
Docker sub-config options:
| Option | Description | Default |
|---|---|---|
dockerfile |
Path to the Dockerfile | required |
imagename |
Docker image name | required |
volumes |
List of volume mounts | required |
ports |
Port mappings | — |
gpus |
GPU flag passed to docker run --gpus |
— |
containerworkdir |
Path inside the container where the project is mounted and commands run from | / |
preserve_paths |
Subpaths under containerworkdir to keep from the image instead of the bind-mounted host copy (e.g. .venv, node_modules) — only used in mount delivery |
[] |
code_delivery |
How code reaches the container: mount or bake |
derived from queue.enabled |
Note:
slotsmoved from thedockerblock to thequeueblock. Adocker.slotsvalue is still honored with a deprecation warning.
Code delivery: mount vs bake
code_delivery controls how your code gets into the container:
mount— bind-mount the synced project overcontainerworkdirat run time (withpreserve_pathsprotecting image-built artifacts). No rebuild per submit, so it's ideal for tight edit-run iteration. The code is read when the container starts.bake— build the code into the image and run that image with no code mount. Each submit builds (Docker layer caching keeps this cheap when only source changed).
When unset it defaults by mode: bake when queue.enabled is true, mount otherwise.
The reason is drift. A queued job may sit in the queue before it runs; with mount it would
pick up whatever code is on disk when it dequeues, so editing code after submitting silently
changes an already-queued job. bake pins each queued submit to an immutable, content-addressed
image tag (derived from the git commit, plus a hash of uncommitted changes) so the job runs
exactly the code it was submitted with. Without a queue there's no drift window, so mount's
zero-rebuild iteration wins. Set code_delivery explicitly to override the default either way.
Data
volumesare always mounted regardless ofcode_delivery; only the code mount differs. For guaranteed-immutable queued builds, commit before submitting — untracked file contents are not folded into the image tag.
Each distinct baked submit produces its own image tag, and resubmit reruns the exact tag a job
originally built. Those tags accumulate over time; run dockhand prune to remove baked images that
no running or queued job still references (--dry-run to preview, -y to skip the prompt).
Profiles
Use profiles to switch between different configurations:
{
"docker": {
"dockerfile": "Dockerfile",
"imagename": "my-image"
},
"profiles": {
"prod": {
"queue": { "slots": 8 },
"docker": {
"gpus": "all",
"dockerfile": "Dockerfile.prod"
}
},
"dev": {
"queue": { "slots": 2 },
"docker": {
"gpus": "1",
"dockerfile": "Dockerfile.dev"
}
}
}
}
dockhand --profile dev submit 'python train.py'
dockhand --profile prod submit 'python train.py'
Complete Example
{
"sync": true,
"ssh": {
"user": "myuser",
"identityfile": "~/.ssh/id_rsa",
"hostname": "gpu-server.example.com"
},
"docker": {
"dockerfile": "Dockerfile",
"imagename": "my-training-app",
"volumes": [
{
"hostpath": "/local/data",
"containerpath": "/data",
"permissions": "rw"
},
{
"hostpath": "/local/models",
"containerpath": "/models",
"permissions": "rw"
}
],
"ports": ["6006:6006"],
"gpus": "all",
"containerworkdir": "/app"
},
"queue": {
"enabled": true,
"slots": 4
},
"remote_path": "/home/myuser/projects/my-app",
"profiles": {
"quick": {
"queue": { "slots": 1 },
"docker": {
"gpus": "1"
}
}
}
}
How It Works
- Build (
install): Optionally syncs local code via rsync, then runsdocker buildon the host. Pass-vto stream build output; default shows a spinner only. Only needed when dependencies or the Dockerfile change. - Submit (
submit/run): Optionally syncs code to the remote, then starts adocker run. With the queue enabled it's submitted to task spooler (tsp); with the queue disabled it runs immediately as a detached container. How code reaches the container depends oncode_delivery: inmountmode the project directory is bind-mounted atcontainerworkdir(no rebuild;preserve_pathsprotects image-built artifacts like a.venvfrom being shadowed by the mount); inbakemode the code is built into an image — content-addressed and immutable for queued jobs — and run with no code mount. - Queue (when enabled): tsp runs jobs one at a time in submission order. Jobs with
--slots Nonly start when N free slots are available. With the queue disabled, jobs start on submit and this ordering doesn't apply. - Job IDs: Each submission gets a local job ID (stable, auto-incrementing) stored alongside the host and the transport handle (tsp job number, or container name). The local ID is what all commands accept.
- Logs (
logs): Reads from the tsp output file (queued jobs) or viadocker logs(direct runs). Use--follow/-fto stream a running job live,--n Nfor the last N lines. - Port Forwarding (
tunnel): Establishes SSH local port forwards for container ports. - Download: Maps containerworkdir-relative paths to host paths and uses rsync to fetch files.
License
MIT
Metadata
Release files for dockhand-cli 0.4.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| dockhand_cli-0.4.0.tar.gz | 2.8 MB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| dockhand_cli-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 2.8 MB
Release files / dockhand_cli-0.4.0.tar.gz
| Download URL | dockhand_cli-0.4.0.tar.gz |
|---|---|
| Size | 2.8 MB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
316f8b24fb5ff47306ed1217e4a6cdd5d90adb39d616c8282f2608b2ed8bd20d
|
|
BLAKE2b-256 checksum How to use checksums |
be4f063b0430676dba2bae5a2724bd84914b658661041cbb79896c96d6ea06a9
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / dockhand_cli-0.4.0-py3-none-any.whl
| Download URL | dockhand_cli-0.4.0-py3-none-any.whl |
|---|---|
| Size | 40.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
6c97c0645d1b94f7aeedf1d7c2b2f8e7553bcb05d9a4eeab8cae91989f60174d
|
|
BLAKE2b-256 checksum How to use checksums |
56f4903b1177ddab15895167d768541e7c3ccb79a5a5e788d9847a990fd87ef7
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
uv/0.13.0 {"installer":{"name":"uv","version":"0.13.0","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|