Docker Deep Dive¶
This page covers the Docker build infrastructure, optimization techniques, and CI/CD pipeline behind the project. For basic setup and deployment, see Quick Start. For container architecture and networking, see Architecture.
Docker Images at a Glance¶
The project publishes two Docker images to Docker Hub:
| Image | Purpose | Base | Stages |
|---|---|---|---|
oaklight/autossh-tunnel |
Core tunnel manager | Alpine 3.22.0 | 2 (builder + runtime) |
oaklight/autossh-tunnel-web-panel |
Web management UI | Alpine 3.22.0 | 2 (builder + runtime) |
Both images support 8 architectures and produce minimal images (~18-20 MB).
Dockerfile Walkthrough¶
autossh-tunnel (Dockerfile)¶
A 2-stage build that compiles a Go WebSocket server and packages it with shell scripts and runtime dependencies.
# Stage 1: Build the Go WebSocket server binary
ARG REGISTRY_MIRROR=docker.io
FROM ${REGISTRY_MIRROR}/library/golang:1.24-alpine AS ws-builder
ARG GOPROXY
WORKDIR /app
COPY ws-server .
RUN rm -f go.mod go.sum && \
if [ -n "$GOPROXY" ]; then export GOPROXY="$GOPROXY"; fi && \
go mod init ws-server && go mod tidy && go mod download
RUN GOMAXPROCS=1 CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o ws-server .
# Stage 2: Runtime image with autossh and all scripts
FROM ${REGISTRY_MIRROR}/library/alpine:3.22.0 AS base
ARG VERSION=dev
RUN apk add --no-cache autossh flock inotify-tools netcat-openbsd socat su-exec
RUN addgroup -g 1000 mygroup && adduser -D -u 1000 -G mygroup myuser
COPY autossh-cli /usr/local/bin/autossh-cli
COPY scripts /usr/local/bin/scripts
COPY spinoff_monitor.sh /usr/local/bin/spinoff_monitor.sh
COPY entrypoint.sh /entrypoint.sh
COPY --from=ws-builder /app/ws-server /usr/local/bin/ws-server
# ... chmod, version embed, entrypoint setup
Key points:
- Stage 1 (
ws-builder) only exists to compile the Go binary — the Go toolchain never ships in the final image - Stage 2 (
base) installs lightweight runtime dependencies viaapkand copies all scripts + the compiled binary COPY --from=ws-builderbridges the two stages
autossh-tunnel-web-panel (Dockerfile.web)¶
A 2-stage build for the web panel Go server.
# Stage 1: Build the web panel binary
ARG REGISTRY_MIRROR=docker.io
FROM ${REGISTRY_MIRROR}/library/golang:tip-alpine AS builder
ARG GOPROXY
ARG VERSION=dev
WORKDIR /app
COPY web .
RUN rm -f go.mod go.sum && go mod init app
RUN if [ -n "$GOPROXY" ]; then export GOPROXY="$GOPROXY"; fi && \
go mod tidy && go mod download
RUN GOMAXPROCS=1 CGO_ENABLED=0 go build \
-ldflags="-s -w -X main.version=$VERSION" \
-trimpath -o app .
# Stage 2: Minimal runtime
ARG REGISTRY_MIRROR=docker.io
FROM ${REGISTRY_MIRROR}/library/alpine:3.22.0
RUN apk add --no-cache su-exec
RUN addgroup -g 1000 mygroup && adduser -D -u 1000 -G mygroup myuser
WORKDIR /app
COPY --from=builder /app/app .
COPY web/static /app/static
COPY web/templates /app/templates
COPY web/entrypoint.sh /entrypoint.sh
Why golang:tip-alpine?
The web panel uses golang:tip-alpine (latest development version) instead of the stable golang:1.24-alpine used by ws-server. This provides access to the latest Go standard library features used by the web panel.
Build Optimization Techniques¶
Go Binary Optimization¶
Every Go binary in this project is compiled with three optimization flags:
| Flag | Purpose | Impact |
|---|---|---|
CGO_ENABLED=0 |
Static linking, no C library dependency | Essential for Alpine (musl vs glibc); produces fully self-contained binary |
-ldflags="-s -w" |
Strip symbol table (-s) and DWARF debug info (-w) |
Reduces binary size by ~30% |
-trimpath |
Remove local filesystem paths from binary | Reproducible builds; no path leakage |
The web panel additionally uses -X main.version=$VERSION to embed the version string at compile time.
GOMAXPROCS=1 for QEMU Cross-Compilation¶
GOMAXPROCS=1 forces single-threaded Go compilation. This prevents deadlocks that occur when QEMU user-mode emulation runs concurrent goroutines during cross-compilation for certain architectures (notably arm/v6, arm/v7, 386, riscv64).
When does this matter?
Only during cross-compilation via docker buildx + QEMU. Native builds on the target architecture don't need this. The small performance cost (slightly slower builds) is negligible compared to the reliability gain.
Alpine Linux Base Image¶
Alpine Linux 3.22.0 provides a ~5 MB base image, keeping final images under 20 MB. Alpine uses musl libc instead of glibc, which is why CGO_ENABLED=0 (static linking) is essential — dynamically linked Go binaries would fail to find glibc at runtime.
Runtime dependencies are installed via apk add --no-cache:
autossh— persistent SSH connectionssocat— API server socket handlingsu-exec— lightweight privilege dropping (Alpine alternative togosu)inotify-tools— config file change monitoringflock— file locking for concurrent accessnetcat-openbsd— network utilities
Building Locally¶
Makefile Target Reference¶
| Target | Description |
|---|---|
build-autossh |
Build multi-arch autossh-tunnel image (cache only) |
build-web |
Build multi-arch web-panel image (cache only) |
build |
Build both images |
push-autossh |
Push autossh-tunnel to Docker Hub |
push-web |
Push web-panel to Docker Hub |
push |
Push both images |
build-and-push-autossh |
Build and push autossh-tunnel |
build-and-push-web |
Build and push web-panel |
all |
Build and push both (default target) |
build-test-autossh |
Build autossh-tunnel for local testing (amd64 only) |
build-test-web |
Build web-panel for local testing (amd64 only) |
build-test |
Build both for local testing |
clean |
Remove local Docker images |
clean-cache |
Remove Docker buildx cache |
Registry Mirror Support (REGISTRY_MIRROR)¶
All FROM instructions use a configurable registry mirror:
This allows building in regions where Docker Hub is throttled or blocked:
# Use a mirror for all base image pulls
REGISTRY_MIRROR=docker.1ms.run make build
# Or for a specific mirror
REGISTRY_MIRROR=docker.xuanyuan.me make build-test
The REGISTRY_MIRROR variable defaults to docker.io (official Docker Hub). The Makefile passes it as a build argument to all docker buildx build commands.
Go Module Proxy Support (GOPROXY)¶
Go module downloads can be routed through a proxy for faster builds:
In the Dockerfile, GOPROXY is conditionally exported only when set:
ARG GOPROXY
RUN if [ -n "$GOPROXY" ]; then export GOPROXY="$GOPROXY"; fi && \
go mod tidy && go mod download
The Makefile only includes the build argument when GOPROXY is non-empty:
Local Testing with --load¶
Multi-arch builds cannot be loaded into the local Docker daemon (they produce manifests for multiple platforms). The build-test targets build single-arch (amd64) images with --load to make them available locally:
# Build for local testing
make build-test
# Then run with dev compose
docker compose -f compose.dev.yaml up
Tip
Use build-test for development iteration. Use build + push for publishing.
Multi-Architecture Builds¶
Supported Platforms¶
| Platform | Architecture | Common Devices |
|---|---|---|
linux/amd64 |
x86-64 | Standard servers, desktops, cloud VMs |
linux/arm64/v8 |
ARM 64-bit | Raspberry Pi 4/5, Apple Silicon (Linux), AWS Graviton |
linux/arm/v7 |
ARM 32-bit v7 | Raspberry Pi 2/3 (32-bit OS), older ARM boards |
linux/arm/v6 |
ARM 32-bit v6 | Raspberry Pi Zero, Pi 1 |
linux/386 |
x86 32-bit | Legacy 32-bit systems |
linux/ppc64le |
PowerPC 64-bit LE | IBM POWER systems |
linux/s390x |
IBM Z | IBM mainframes |
linux/riscv64 |
RISC-V 64-bit | RISC-V development boards |
How buildx + QEMU Works¶
Multi-arch builds use Docker Buildx with QEMU user-mode emulation:
- QEMU emulates the target CPU architecture on the build host
- Buildx orchestrates parallel builds for each platform
- Each platform's layers are built independently and pushed as a multi-platform manifest
No real hardware is needed — a standard x86-64 machine can build for all 8 platforms. The trade-off is build time: emulated builds are significantly slower than native builds.
Build vs Push Workflow¶
The Makefile separates building and pushing:
# Step 1: Build and cache all platform layers (no push)
make build
# Step 2: Verify builds succeeded, then push from cache
make push
This separation allows build verification before publishing. The build-and-push-* targets combine both steps for convenience.
Entrypoint Pattern¶
autossh Container (entrypoint.sh)¶
The main entrypoint runs as root and performs initialization before dropping privileges:
- Export environment variables for autossh-cli (
AUTOSSH_CONFIG_FILE,SSH_CONFIG_DIR,AUTOSSH_STATE_FILE, WebSocket variables) - Dynamic PUID/PGID — modifies
/etc/passwdand/etc/groupin-place viasedto match the host user's UID/GID: - Initialize state — create
/tmpdirectories, clean old logs and state files, set permissions - Fix home directory ownership —
chownthe home directory/home/myuserso the container user can create files (e.g.,~/.autossh-socketsfor interactive auth) - Fix config ownership —
chownthe config directory (Docker creates bind-mount directories as root if they don't exist on the host) - Privilege drop —
exec su-exec myuser "$@"replaces the shell with the target command running asmyuser
su-exec vs gosu
su-exec is preferred in Alpine for its smaller binary size. Unlike sudo or su, su-exec directly exec()s the target command, avoiding a parent process overhead.
For user-facing PUID/PGID configuration, see Quick Start.
Web Container (web/entrypoint.sh)¶
The web panel entrypoint is minimal — it only drops privileges:
No state cleanup is needed because the web panel is a stateless proxy server.
Version Embedding¶
Version information flows through the build pipeline:
Makefileor CI sets theVERSIONbuild argument- autossh container:
echo "$VERSION" > /etc/autossh-version— read at runtime byspinoff_monitor.shfor the startup banner - web container:
-X main.version=$VERSIONinldflags— compiled directly into the Go binary
CI/CD Pipeline¶
Pipeline Overview (docker-publish.yml)¶
The Docker publish workflow triggers on:
- GitHub Release — automatically when a release is published
- Manual dispatch — via
workflow_dispatchwith a version input
┌─────────────┐ ┌──────────┐ ┌────────────┐ ┌──────────────┐
│ Trigger │───>│ go-test │───>│ build-and- │───>│ Docker Hub │
│ (release or │ │ go-fmt │ │ push │ │ (8 arch │
│ dispatch) │ │ shell- │ │ (2 images │ │ per image) │
│ │ │ lint │ │ parallel) │ │ │
└─────────────┘ └──────────┘ └────────────┘ └──────────────┘
Build Strategy¶
The workflow uses a strategy matrix to build both images in parallel:
strategy:
matrix:
include:
- image: oaklight/autossh-tunnel
dockerfile: Dockerfile
platforms: linux/amd64,linux/arm64/v8,...
- image: oaklight/autossh-tunnel-web-panel
dockerfile: Dockerfile.web
platforms: linux/amd64,linux/arm64/v8,...
Each matrix entry sets up:
- QEMU (
docker/setup-qemu-action@v3) — CPU emulation - Buildx (
docker/setup-buildx-action@v3) — multi-platform builder - Docker Hub login (
docker/login-action@v3) — registry authentication - Build and push (
docker/build-push-action@v6) — builds all platforms and pushes
Images are tagged with both latest and the version tag (e.g., v2.3.1).
Build Cache¶
The pipeline uses GitHub Actions cache for Docker layers:
mode=max caches all layers (not just the final image layers), significantly speeding up rebuilds when only application code changes while base images and dependencies remain the same.
CI Smoke Tests (ci.yml)¶
The regular CI pipeline (triggered on push/PR) includes a Docker build job that:
- Builds both images single-arch (no
--platform) for speed - Runs a smoke test on the autossh-tunnel image:
This catches build failures early without the time cost of multi-arch builds.