Locked-down SSH container with sandboxed file operations. Use as a base image to build your own dedicated tool containers - just provide a list of allowed commands and install your binaries. No shell access, no injection bullshit.
Skip the HTTP API wrapper. Any CLI tool instantly becomes a secure, remotely accessible service over SSH - no REST endpoints, no request parsing, no serialization layer. Just ssh myapp@host "tool --flag arg" and you're done. Even better - route it through Cloudflare Tunnel or Tailscale and you've got secure remote access without exposing ports.
put, get, list-files, remove-file, create-dir, remove-dir, remove-dir-recursive, move-file, copy-file, file-info, file-exists, file-hash, disk-usage, search-files, append-file - all locked to /work&&, ;, |, $() are just literal arguments. No shell means shell metacharacters are meaninglessshlex.split() and executes via os.execv() - no shell involved at any point&&, ;, |, $() become literal arguments to the binary. No shell means shell metacharacters are meaningless/workCreate allowed.json with your commands:
{
"ffmpeg": "/usr/bin/ffmpeg",
"ffprobe": "/usr/bin/ffprobe"
}
Create your Dockerfile:
FROM psyb0t/lockbox
ENV LOCKBOX_USER=myapp
RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg && \
apt-get clean && rm -rf /var/lib/apt/lists/*
COPY allowed.json /etc/lockbox/allowed.json
Build and run:
docker build -t myapp .
cat ~/.ssh/id_rsa.pub > authorized_keys
mkdir -p work host_keys
docker run -d \
--name myapp \
--restart unless-stopped \
-p 2222:22 \
-e "LOCKBOX_UID=$(id -u)" \
-e "LOCKBOX_GID=$(id -g)" \
-v $(pwd)/authorized_keys:/etc/lockbox/authorized_keys:ro \
-v $(pwd)/host_keys:/etc/lockbox/host_keys \
-v $(pwd)/work:/work \
myapp
ssh -p 2222 myapp@localhost "ffmpeg -version"
All paths are relative to /work. You can't escape it - traversal attempts get blocked, absolute paths get remapped under /work.
| Command | Description |
|---|---|
list-files | List /work or a subdirectory (--json for JSON output) |
put | Upload file from stdin |
get | Download file to stdout |
append-file | Append stdin to an existing file |
remove-file | Delete a file (not directories) |
create-dir | Create directory (recursive) |
remove-dir | Remove empty directory |
remove-dir-recursive | Remove directory and everything in it recursively |
move-file | Move/rename a file or directory within /work |
copy-file | Copy a file within /work |
file-info | JSON metadata for a path (size, mode, owner, etc) |
file-exists | Print true or false |
file-hash | SHA-256 hex digest of a file |
disk-usage | Total bytes used in /work or a subpath |
search-files | Glob pattern search (recursive) within /work |
# Upload a file
ssh lockbox@host "put input.txt" < input.txt
# Download a file
ssh lockbox@host "get output.txt" > output.txt
# List files
ssh lockbox@host "list-files"
ssh lockbox@host "list-files subdir"
# List files as JSON
ssh lockbox@host "list-files --json"
# Create a directory
ssh lockbox@host "create-dir project1"
# Delete a file
ssh lockbox@host "remove-file input.txt"
# Delete an empty directory
ssh lockbox@host "remove-dir project1"
# Nuke a directory and everything in it
ssh lockbox@host "remove-dir-recursive project1"
# Move/rename a file
ssh lockbox@host "move-file old.txt new.txt"
# Copy a file
ssh lockbox@host "copy-file original.txt backup.txt"
# Get file metadata as JSON
ssh lockbox@host "file-info output.txt"
# Check if a file exists
ssh lockbox@host "file-exists output.txt"
# Get SHA-256 hash
ssh lockbox@host "file-hash output.txt"
# Check disk usage (bytes)
ssh lockbox@host "disk-usage"
ssh lockbox@host "disk-usage subdir"
# Search for files by glob pattern
ssh lockbox@host "search-files **/*.txt"
# Append to an existing file
echo "more data" | ssh lockbox@host "append-file output.txt"
/etc/lockbox/allowed.json)A JSON object mapping command names to binary paths. The base image ships with {} (no external commands allowed, only file operations). Downstream images override this:
{
"mytool": "/usr/bin/mytool",
"another": "/usr/local/bin/another"
}
/etc/lockbox/entrypoint.d/*.sh)Drop executable .sh scripts in /etc/lockbox/entrypoint.d/ to run custom init logic before sshd starts. Scripts run in sorted order. Example - a font cache rebuild hook:
COPY --chmod=755 10-fontcache.sh /etc/lockbox/entrypoint.d/10-fontcache.sh
LOCKBOX_USER env var)Sets the SSH username for the container. The entrypoint renames the system user and updates sshd config at startup. Defaults to lockbox. Set it in your downstream Dockerfile:
ENV LOCKBOX_USER=myapp
Users then connect with ssh myapp@host instead of ssh lockbox@host.
| Variable | Default | Description |
|---|---|---|
LOCKBOX_UID | 1000 | UID for the lockbox user (match host) |
LOCKBOX_GID | 1000 | GID for the lockbox user (match host) |
LOCKBOX_USER | lockbox | SSH username (renames system user at startup) |
| Path | Description |
|---|---|
/work | Input/output files - your workspace |
/etc/lockbox/authorized_keys | SSH public keys (mount read-only) |
/etc/lockbox/host_keys | SSH host keys (persists across container recreates) |
If you're building a downstream image, create_installer.sh generates a complete install.sh for your project from a YAML config. Grab it and feed it your config:
curl -fsSL https://raw.githubusercontent.com/psyb0t/docker-lockbox/main/create_installer.sh | bash -s installer.yml > install.sh
Example config:
name: myapp
image: psyb0t/myapp
repo: psyb0t/docker-myapp
volumes:
- flag: -d
env: DATA_DIR
mount: /data:ro
default: ./data
description: Data directory
environment:
- flag: -w
env: WORKERS
container_env: APP_WORKERS
default: 4
description: Number of workers
- flag: --log-level
env: LOG_LEVEL
container_env: APP_LOG_LEVEL
default: info
description: Log level (debug, info, warn, error)
| Field | Description |
|---|---|
name | CLI command name, home dir name (~/.myapp/), compose service name |
image | Docker image to pull |
repo | GitHub repo for curl | bash upgrades |
volumes | Extra volumes with CLI flags for runtime configuration |
environment | Environment variables passed into the container |
Each volumes entry:
| Key | Description |
|---|---|
flag | CLI flag for the start command (e.g. -d) |
env | Variable name in .env file, prefixed with uppercase app name (e.g. MYAPP_DATA_DIR) |
mount | Container mount path, with optional :ro suffix |
default | Default host path relative to app home |
description | Help text shown in CLI usage |
Each environment entry:
| Key | Description |
|---|---|
flag | CLI flag for the start command (e.g. -w, --log-level) |
env | Variable name in .env file, prefixed with uppercase app name (e.g. MYAPP_WORKERS) |
container_env | Actual env var name inside the container (e.g. APP_WORKERS) |
default | Default value |
description | Help text shown in CLI usage |
The generated install.sh gives users a one-liner install (curl | sudo bash) that sets up ~/.myapp/ with docker-compose, authorized_keys, host_keys, work dir, and a CLI wrapper with start, stop, upgrade, uninstall, status, and logs commands. Resource limits (--cpus, --memory, --swap) are always available - defaults to unlimited if not specified.
Using the example config above, the generated .env:
MYAPP_PORT=2222
MYAPP_DATA_DIR=$MYAPP_HOME/data
MYAPP_WORKERS=4
MYAPP_LOG_LEVEL=info
MYAPP_PROCESSING_UNIT=cpu
MYAPP_GPUS=all
MYAPP_CPUS=0
MYAPP_MEMORY=0
MYAPP_SWAP=0
The generated docker-compose.yml:
services:
myapp:
image: psyb0t/myapp
ports:
- "${MYAPP_PORT:-2222}:22"
environment:
- LOCKBOX_UID=${REAL_UID}
- LOCKBOX_GID=${REAL_GID}
- APP_WORKERS=${MYAPP_WORKERS:-4}
- APP_LOG_LEVEL=${MYAPP_LOG_LEVEL:-info}
- PROCESSING_UNIT=${MYAPP_PROCESSING_UNIT:-cpu}
volumes:
- ./authorized_keys:/etc/lockbox/authorized_keys:ro
- ./host_keys:/etc/lockbox/host_keys
- ./work:/work
- ${MYAPP_DATA_DIR:-./data}:/data:ro
cpus: ${MYAPP_CPUS:-0}
mem_limit: ${MYAPP_MEMORY:-0}
memswap_limit: ${MYAPP_MEMSWAP:-0}
restart: unless-stopped
Every generated installer includes GPU/processing unit support. Two compose overlays are generated alongside the base docker-compose.yml. The CLI wrapper's compose() function automatically merges the right one based on --processing-unit:
--processing-unit cuda → merges docker-compose.cuda.yml (nvidia driver, NVIDIA_VISIBLE_DEVICES)--processing-unit rocm → merges docker-compose.rocm.yml (AMD /dev/kfd, /dev/dri, HIP_VISIBLE_DEVICES)--processing-unit cpu (default) → no overlayThe --gpus flag controls which GPUs are exposed — maps to the vendor-specific env var in each overlay.
The generated CLI wrapper:
myapp start -d # start detached (cpu mode)
myapp start -d --processing-unit cuda --gpus 0 # NVIDIA GPU 0
myapp start -d --processing-unit cuda --gpus all # all NVIDIA GPUs
myapp start -d --processing-unit rocm --gpus 0 # AMD GPU 0
myapp start -d -w 8 --log-level debug # 8 workers, debug logging
myapp start -d --port 3333 --cpus 4 --memory 8g # custom port, resource limits
myapp stop # stop
myapp status # show container status
myapp logs -f # follow logs
myapp upgrade # pull latest image and re-install
myapp uninstall # stop and remove everything
make build
make test # builds test image and runs integration tests
| Image | Description |
|---|---|
| psyb0t/mediaproc | FFmpeg, Sox, ImageMagick, 2200+ fonts - media processing over SSH |
| psyb0t/qwenspeak | Qwen3-TTS text-to-speech over SSH - preset voices, voice cloning, voice design |
This project is licensed under WTFPL - Do What The Fuck You Want To Public License.
Content type
Image
Digest
sha256:f1975a06a…
Size
44.7 MB
Last updated
3 months ago
docker pull psyb0t/lockbox