Run the catalogue pull from the published image, keep the profile on a volume, and bake the binary into another image for a scheduled pull.
Every release publishes the same binary as an image, to two registries:
ghcr.io/pksorensen/pks-agent-woocommerce:<version> # and :latest
registry.agentics.dk/agentics/pks-agent-woocommerce:<version>
The GHCR one is public — no login to pull it.
The credentials come from the environment, which is the only shape that works
unattended — init prompts, and a scheduled task has no terminal:
docker run --rm \
-e PKS_WOO_SITE=https://shop.example.com \
-e PKS_WOO_KEY -e PKS_WOO_SECRET \
-v "$PWD/woo:/work/woo" \
ghcr.io/pksorensen/pks-agent-woocommerce:latest product sync --with-variations
-e PKS_WOO_KEY with no =value passes the variable through from your own
shell, so the secret is not in the command you typed and not in your shell
history. Nothing is written to disk: with these set, the CLI never creates its
config directory, so the container leaves no credential behind.
Set all three of PKS_WOO_SITE, PKS_WOO_KEY and PKS_WOO_SECRET, or none. A
partial set is refused rather than falling back to a stored profile, because a
typo in one of them would otherwise pull a different shop's catalogue and look
like it worked. PKS_WOO_APP_PASSWORD (user:application password) is optional;
so is PKS_WOO_REST_MODE (pretty | route), which skips one probe per run.
/work is the working directory, so --dir-relative output (./woo by
default) lands on your disk instead of inside a container that is about to be
removed. Files come out owned by root unless you add
--user "$(id -u):$(id -g)".
For repeated work from a laptop, init stores the store URL and credential on a
volume:
docker run --rm -it -v woo-profile:/data -v "$PWD/woo:/work/woo" \
ghcr.io/pksorensen/pks-agent-woocommerce:latest init
docker run --rm -v woo-profile:/data -v "$PWD/woo:/work/woo" \
ghcr.io/pksorensen/pks-agent-woocommerce:latest product sync --with-variations
/data is the profile store (PKS_WOO_HOME); without a volume there, every
command starts from no connected store. init needs -it: it prompts, and it
refuses a pipe. Every command after it is non-interactive.
There is no keyring in a container and no Secret Service to talk to, so the
image sets PKS_WOO_NO_KEYRING=1 and the credential is stored as a 0600 file
inside a 0700 directory on /data. That is a decision, not a fallback:
whatever can read that volume can read the key. Treat the volume as the secret —
and prefer the environment for anything that runs on a schedule, where the
volume would outlive the run and nothing would rotate it.
The binary is static, so it can be copied out of this image and run on any Linux base — Debian slim, Ubuntu, distroless with a shell. A downstream image that wants a catalogue pull of its own does not need Go, and does not need this image at runtime:
FROM ghcr.io/pksorensen/pks-agent-woocommerce:1.0.0 AS woo-cli
FROM node:22-bookworm-slim
COPY --from=woo-cli /usr/local/bin/pks-agent-woocommerce-cli /usr/local/bin/
Pin the version. :latest in a FROM makes an image that builds differently on
different days, which is the one property a build should not have.
Every release smoke-tests exactly this path — the binary is copied out of the published image and run on Debian slim — so a release that would break your build fails ours first.
A scheduled pull is one command, with the three variables injected by whatever
runs it — Coolify's scheduled tasks, a Kubernetes CronJob's secretKeyRef, a
systemd unit's EnvironmentFile. Write into a staging directory and move it into
place when the pull is finished, so whatever reads the output never sees half a
catalogue:
#!/bin/sh
set -eu
# PKS_WOO_SITE / PKS_WOO_KEY / PKS_WOO_SECRET come from the scheduler's
# environment. Never echo them, and never write them to the output directory.
stamp=$(date -u +%Y%m%d-%H%M)
out=/work/out # a mount of its own — output, not credentials
staging="$out/$stamp.part"
for lang in da en de fr; do
pks-agent-woocommerce-cli product sync \
--dir "$staging/$lang" --lang "$lang" --with-variations
done
mv "$staging" "$out/$stamp" # atomic: the directory is complete or absent
Three things worth deciding before the first scheduled run: