LocalGo supports Docker and Podman with two image variants:
| Variant | Image tag | Base | Size | Includes shell |
|---|---|---|---|---|
| Standard | ghcr.io/bethropolis/localgo:latest |
Alpine | ~25 MB | Yes |
| Scratch | ghcr.io/bethropolis/localgo:scratch |
scratch |
~10 MB | No |
The scratch image is recommended for production. It contains only the Go binary and CA certificates — no shell, no package manager, minimal CVE surface. For development or when you need shell access, use the standard image.
mkdir -p downloads config
docker run -d \
--name localgo \
--network host \
-v $(pwd)/downloads:/app/downloads:z \
-v $(pwd)/config:/app/config:z \
-e PUID=$(id -u) -e PGID=$(id -g) \
ghcr.io/bethropolis/localgo:scratchmkdir -p downloads config
docker run -d \
--name localgo \
--network host \
-v $(pwd)/downloads:/app/downloads:z \
-v $(pwd)/config:/app/config:z \
ghcr.io/bethropolis/localgo:latestdocker compose up -d # start
docker compose logs -f # follow logs
docker compose down # stopUse docker compose -f docker-compose.scratch.yml up -d for the scratch image.
- No shell — no
/bin/sh, nosu-exec, no package manager - Go-native permission fix —
localgo docker-startreadsPUID/PGID,chowns the volume mounts, then drops privileges viasyscall.Setuid/Setgid - Smaller attack surface — ideal for security-conscious deployments
- Requires
PUID/PGID— must be set so the binary can fix permissions before dropping privileges
- Alpine-based — includes
/bin/sh,su-exec,wget - Shell access —
docker exec -it localgo /bin/sh - Entrypoint script — handles Podman/Docker uid_map detection and permission fixes automatically
- No special environment variables required
The localgo docker-start command (used as the container ENTRYPOINT):
- Reads
PUIDandPGIDfrom environment (default1000) os.Chownon/app/downloadsand/app/configto match the UID/GIDsyscall.Setgid(pgid)thensyscall.Setuid(puid)execslocalgo serve— the process replaces itself with the unprivileged UID
-e PUID=$(id -u) -e PGID=$(id -g)The entrypoint detects the runtime and acts accordingly:
| Scenario | Detection | Action |
|---|---|---|
Non-root user (--user) |
id -u != 0 |
exec directly |
| Rootless Podman (default) | uid_map shows UID 0 → host UID N |
chown -R 0:0 volumes (re-maps to host user on-disk), exec as UID 0 |
| Rootful Docker / Podman | uid_map shows UID 0 → host UID 0 |
chown -R $PUID:$PGID volumes, drop to localgo via su-exec |
For rootful Docker, set PUID/PGID to match your host user:
-e PUID=$(id -u) -e PGID=$(id -g)| Mount | Purpose |
|---|---|
/app/downloads |
Received files (must be writeable) |
/app/config |
TLS certificates + device fingerprint (persist across restarts) |
Always create the host directories before starting the container (Podman does not auto-create them):
mkdir -p downloads config
:zis required on SELinux hosts (Fedora, RHEL, CentOS). It is harmless on non-SELinux systems.
| Variable | Description | Default |
|---|---|---|
LOCALSEND_ALIAS |
Device name shown to peers | hostname |
LOCALSEND_PORT |
Listening port | 53317 |
LOCALSEND_PIN |
Require a PIN from senders | (none) |
LOCALSEND_FORCE_HTTP |
Disable HTTPS | false |
LOCALSEND_AUTO_ACCEPT |
Accept files without prompting | false |
LOCALSEND_NO_CLIPBOARD |
Save text as file instead of clipboard | false |
LOCALSEND_DEVICE_TYPE |
mobile / desktop / server / headless / … |
desktop |
LOCALSEND_DEVICE_MODEL |
Model string | LocalGo |
LOCALSEND_LOG_LEVEL |
debug / info / warn / error |
info |
PUID |
Host user ID for file ownership (scratch + rootful Docker) | 1000 |
PGID |
Host group ID for file ownership (scratch + rootful Docker) | 1000 |
SKIP_PERMS_FIX |
Skip the entrypoint permission fix (standard image only) | false |
Clipboard note: No clipboard tools are present in either image. Incoming
text/plaintransfers are saved as.txtfiles automatically.
--network hostRequired for multicast device discovery. The container shares the host's network interfaces directly.
Docker and Podman run inside a VM on these platforms, so --network host binds to the VM's network, not the host computer's. Multicast discovery will not reach the real LAN.
Option 1 — Port mapping (simple but multicast still broken):
-p 53317:53317/tcp -p 53317:53317/udpOption 2 — Macvlan (recommended for Mac/Windows) — gives the container a real LAN IP:
Macvlan gives the container its own IP address on your actual home router, bypassing the VM entirely. Create docker-compose.macvlan.yml:
NETWORK_INTERFACE=eth0 docker compose -f docker-compose.macvlan.yml up -dSet NETWORK_INTERFACE to your host's network interface name (eth0, en0, wlan0, etc.). Assign a static IP on your router's DHCP reservation.
The scratch image supports a fully read-only root filesystem. Only /app/downloads and /app/config need to be writeable:
read_only: true
security_opt:
- no-new-privileges:true
volumes:
- ./downloads:/app/downloads:z
- ./config:/app/config:zThe Go binary writes logs to stdout/stderr (captured by docker logs), so no log directory is needed.
Both images expose a built-in health check via localgo health:
docker inspect localgo | jq '.[0].State.Health.Status'
podman inspect localgo --format '{{.State.Health.Status}}'localgo health hits http://127.0.0.1:{port}/api/localsend/v2/info and exits 0 on HTTP 200, 1 otherwise. The scratch image works because the health check is compiled into the binary — no external tools required.
If you enable LOCALSEND_FORCE_HTTP=false to use TLS, the health check still works (it hits HTTP, the server always listens on HTTP for the API regardless of TLS settings for file transfers).
LocalGo handles SIGTERM gracefully:
- The
servecommand listens forSIGINT/SIGTERM - On signal, it calls
httpServer.Shutdown()— rejecting new uploads - Active file transfers complete or are cleanly aborted (
.partfiles are removed) - The transfer history (
history.jsonl) is flushed to disk
When using docker compose stop, Docker sends SIGTERM and waits up to 10 seconds. LocalGo exits promptly, so active transfers are cleanly handled.
Both images include OCI labels for automated updater tools:
LABEL com.centurylinklabs.watchtower.enable="true"
LABEL org.opencontainers.image.ref.name="localgo"
LABEL org.opencontainers.image.vendor="Bethropolis"Tools like Watchtower or Diun will automatically detect and update LocalGo containers when a new image is pushed.
docker build -t localgo .docker build -f Dockerfile.scratch -t localgo:scratch .docker build \
--build-arg VERSION=$(git describe --tags --always) \
--build-arg GIT_COMMIT=$(git rev-parse --short HEAD) \
--build-arg BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ') \
-t ghcr.io/bethropolis/localgo:scratch \
-f Dockerfile.scratch .For Podman, replace docker with podman.
Podman doesn't auto-create bind-mount source directories. Create them first:
mkdir -p downloads config- Make sure you created the directories before running the container.
- On SELinux, add
:zto volume flags. - For scratch or rootful Docker, set your UID/GID:
-e PUID=$(id -u) -e PGID=$(id -g)
- Linux: verify
--network hostis set. - Check your firewall allows UDP port 53317.
- Use HTTP scan as fallback:
docker exec localgo localgo scan
If /app/config is not mounted to a persistent path, a new TLS certificate is generated on each start. Other LocalSend clients will see it as a different device. Always mount /app/config.
- Set
LOCALSEND_PINin any environment with untrusted peers. - Mount
/app/configto persist a stable device fingerprint. - Use the scratch image for production (minimal attack surface).
- Enable
read_only: truewhere your orchestrator supports it. - Set
LOCALSEND_AUTO_ACCEPT=falseif you want manual approval.