Prerequisites

Parallax’s headline claim is that target hosts need no Python. That is true, and it is worth being precise about what it costs: the work Ansible does with a Python interpreter on the target, Parallax does with a Go toolchain on the controller. Read this page before installing.

Controller requirements

A Go toolchain is required at runtime — not just to build

This is the single most surprising requirement, so it comes first.

Parallax modules are Go programs. They are not shipped inside the parallax binary as precompiled payloads; they are compiled on the controller, on demand, for the architecture of each target host, and the resulting static binary is copied over and executed. pkg/module/compiler/compiler.go shells out to go build to do this.

The compile is roughly:

CGO_ENABLED=0 GOOS=<target-os> GOARCH=<target-arch> \
  go build -tags netgo,osusergo -ldflags "-s -w -extldflags '-static'" -o <module> .

netgo and osusergo plus CGO_ENABLED=0 are what make the module a fully static binary with no libc, DNS-resolver or NSS dependency on the target — that is the mechanism behind “no Python, no runtime dependencies on the target”.

So on the controller you need:

RequirementNotes
Go 1.25 or newerMust be on PATH as go. Match the version in go.mod.
Network access to the Go module proxygo mod tidy runs when generating a module. See Offline operation.
Writable GOCACHESee Warm your build cache.
A writable temp directoryEach compile gets its own os.MkdirTemp scratch directory.

If go is not on PATH, the first task that needs a module fails with:

parallax requires a Go toolchain on the controller to compile modules,
but 'go' was not found on PATH: exec: "go": executable file not found in $PATH

That check is a preflight in the compiler, so the failure is reported up front rather than surfacing as an opaque exec error somewhere inside module execution.

Warm your build cache

Compilation happens per module, per target GOOS/GOARCH. A cold GOCACHE means the standard library gets compiled for that target before your module does, which turns a sub-second step into tens of seconds. A warm cache turns it back into milliseconds. This is the difference between Parallax feeling instant and feeling broken, so it is worth arranging deliberately:

  • Do not wipe GOCACHE between runs. In CI, cache the GOCACHE directory (go env GOCACHE, usually ~/.cache/go-build) along with the module cache.

  • In a container image, pre-warm the cache at build time for the architectures you actually target:

    RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -tags netgo,osusergo std && \
        CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -tags netgo,osusergo std
    
  • Parallax also caches the compiled module binaries themselves (--cache, on by default, in ~/.parallax/module-cache). That is a separate layer above the Go build cache, and both matter.

The module SDK source must be findable

Generated modules import the module SDK. Rather than fetching it from the network, Parallax writes a replace directive into the generated go.mod pointing at a local copy of the module SDK source. If it cannot find that source, no replace directive is written and the build falls back to resolving the SDK over the network.

resolveSDKPath in module-sdk/functions.go looks in this order:

  1. PARALLAX_MODULE_SDK_PATH — if set and it names an existing directory.
  2. runtime.Caller(0) — the directory of the SDK source file itself, used only if the recorded path is absolute and still exists on disk.
  3. Known system paths, in order:
    • /usr/local/share/parallax/module-sdk
    • /opt/parallax/module-sdk

If none of these resolve, resolveSDKPath returns an empty string and the replace directive is silently omitted.

The -trimpath trap

Step 2 above does not work in a released binary.

runtime.Caller(0) returns the compile-time source path. Release binaries are built with -trimpath (see .gitlab-ci.yml, the release:binaries job), which is exactly what -trimpath removes: the recorded path becomes a module-relative path such as go.digitalxero.dev/parallax-module-sdk/functions.go, not an absolute filesystem path. The filepath.IsAbs guard then rejects it and resolution falls through.

The practical consequence:

For any binary you downloaded rather than built yourself, PARALLAX_MODULE_SDK_PATH or one of the two known system paths is effectively mandatory.

It fails softly rather than loudly — you get a network fetch of the SDK, or a build error about a missing module, rather than a clear “SDK not found” message. If module compilation fails on a released binary and works from a go run checkout, this is almost certainly why.

Set it explicitly:

export PARALLAX_MODULE_SDK_PATH=/usr/local/share/parallax/module-sdk

or install the SDK source to /usr/local/share/parallax/module-sdk and leave the variable unset.

Offline operation

GenerateCode runs go mod tidy in the generated module directory unless PARALLAX_OFFLINE is set. Setting it also swaps go fmt for a direct gofmt invocation, avoiding the toolchain’s module resolution entirely:

export PARALLAX_OFFLINE=1

Use this only when the SDK path resolves locally and the module’s imports are all standard library or already in the local module cache — PARALLAX_OFFLINE skips dependency resolution, it does not vendor anything for you.

Reference setup

acceptance/docker/ubuntu.dockerfile is the known-good configuration and is worth copying:

# Go toolchain from the official distribution, on PATH
ENV GOLANG_VERSION 1.25.12
ENV PATH /usr/local/go/bin:${PATH}
RUN wget -q https://go.dev/dl/go${GOLANG_VERSION}.linux-amd64.tar.gz -O go.tgz \
    && tar -C /usr/local -xzf go.tgz \
    && rm go.tgz

# Module SDK source, at the path the resolver already knows about,
# and named explicitly so it does not depend on the resolver's fallbacks
COPY module-sdk /usr/local/share/parallax/module-sdk
ENV PARALLAX_MODULE_SDK_PATH=/usr/local/share/parallax/module-sdk

Note that it does both: it installs to a known system path and sets the environment variable. That belt-and-braces approach is the recommendation.

Target host requirements

Targets need no Python, no agent and no Parallax installation. They do need:

RequirementWhy
An SSH server, reachable from the controllerDefault transport. local, docker and winrm connections are also available.
A shell and a writable temp directoryCompiled module binaries are copied in, executed and removed.
An architecture the controller can cross-compile forAny Go-supported GOOS/GOARCH pair.
sudo / su / doasOnly if you use become.

Module-specific target requirements

Most modules are self-contained static binaries and shell out to nothing. The exceptions are worth knowing about:

ModuleNeeds on the targetNotes
unarchivetar for .tar, .tar.gz, .tar.bz2, .tar.xz; gunzip for a bare .gzZip archives are handled natively via archive/zip and need nothing.
apt, yum, dnf, pip, …The corresponding package managerExpected.
service, systemdThe corresponding init systemExpected.

Note: unarchive’s use of external tar and gunzip reflects current behaviour. Native extraction is in progress; when it lands, this row drops to “nothing” and this note should be removed.

Quick check

On the controller:

go version                       # must print 1.25 or newer
go env GOCACHE                   # must be writable, and ideally already populated
echo "${PARALLAX_MODULE_SDK_PATH:-<unset>}"
ls /usr/local/share/parallax/module-sdk 2>/dev/null || echo "not installed system-wide"

If PARALLAX_MODULE_SDK_PATH is unset and no system path exists and you are running a downloaded release binary, fix that before going further — see The -trimpath trap.

On a target, from the controller:

parallax play -i inventory.ini --check ping.yml

The first run is the slow one; it populates both the Go build cache and the module binary cache.

Next steps