Docker
What it is
can3p/tommy is tommy as a single-binary container image: the same binary the
release archives carry, on gcr.io/distroless/static:nonroot, with every
default listener exposed, the repository's own tommy.toml at
/etc/tommy/tommy.toml, and a writable /data for the things tommy generates
rather than captures. The entrypoint is the binary, so every subcommand — serve,
providers, openapi, the single-plugin shortcuts — works exactly as it does
outside a container.
What it's for
You reach for the image when a Go toolchain is not where tommy needs to run:
- In CI. A job that has to prove your application sent the right email adds
can3p/tommyas a service container and points the SDK's base URL at it. No toolchain, no build step, one pinned tag. - In a compose stack. Your application already runs beside a database and a
queue in
docker compose; tommy joins them as one more service and your app'sMAILJET_API_BASE,SMTP_HOSTorFTP_HOSTpoints at it by service name. - On a machine with no Go. A colleague wants to see what your integration
sends to a hospital system, and
docker runis the whole of the setup.
What tommy captures, and what each plugin and provider stands in for, is in
docs/catalogue.md; nothing about that changes in a container.
This page is only about running it as one.
How to test it for real
Every command below has been run against a real container. CI runs them too, by
extracting the blocks marked # ci: <name> from this file and executing them —
so the commands a reader copies are literally the commands that are proven on
every pull request. Keep them paste-ready and self-contained.
Build the image from this repository
Skip this once can3p/tommy is published — docker run will pull it. From a
checkout, this builds the same Dockerfile GoReleaser uses at release time, and
tags it with the published name so every command below runs against it:
# ci: build
ctx="$(mktemp -d)"
arch="$(go env GOARCH)"
mkdir -p "$ctx/linux/$arch"
CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -o "$ctx/linux/$arch/tommy" .
cp Dockerfile LICENSE tommy.toml "$ctx/"
docker buildx build --platform "linux/$arch" --load -t can3p/tommy:latest "$ctx"
rm -rf "$ctx"
The staged directory is not a quirk of building locally: GoReleaser's
dockers_v2 hands the Dockerfile a context with each prebuilt binary under a
directory named after its platform, which is why the file says
COPY $TARGETPLATFORM/tommy. Mimicking that layout is what lets one Dockerfile
serve both paths, instead of a second one that drifts.
Run it
# ci: run
docker run -d --rm --name tommy \
-p 8811:8811 -p 8822:8822 -p 9000:9000 \
-p 1025:1025 -p 2121:2121 -p 2222:2222 -p 2049:2049 -p 2575:2575 \
-p 6969:6969/udp -p 1162:1162/udp \
-v tommy-data:/data \
can3p/tommy:latest
tommy is running
ui http://localhost:8811/ui/
api http://localhost:8811/api/v1
ingress http://localhost:8822
plugin mail ([mailjet resend sendgrid smtp])
plugin sms ([twilio])
plugin files ([ftp sftp tftp nfs])
plugin chat ([slack msteams])
plugin hl7 ([mllp])
plugin snmp ([trap])
plugin push ([fcm apns])
plugin s3 ([http])
plugin as2 ([http])
run `tommy providers` for copy-paste examples
Every plugin and every provider is on, exactly as tommy serve is outside a
container. docker logs tommy shows the same lines; docker run -P publishes
every exposed port instead of naming them.
Send it something
# ci: quickstart
curl -s http://127.0.0.1:8822/v3.1/send \
-u "any-key:any-secret" -H 'Content-Type: application/json' -d '{
"Messages":[{"From":{"Email":"a@example.com","Name":"Alice"},"To":[{"Email":"b@example.com"}],
"Subject":"Hello from tommy","TextPart":"It works."}]}'
{"Messages":[{"Status":"success","CustomID":"","To":[{"Email":"b@example.com","MessageUUID":"e494ba6b-d5ed-4ecc-bcd5-b147f8bde658","MessageID":100000000000001,"MessageHref":"https://api.mailjet.com/v3/message/100000000000001"}],"Cc":[],"Bcc":[]}]}
Then read it back, or open http://127.0.0.1:8811/ui/:
# ci: readback
curl -s "http://127.0.0.1:8811/api/v1/events?plugin=mail"
[{"id":"01a07385c3a30001f5aebd6a","plugin":"mail","provider":"mailjet","type":"mail.message",
"received_at":"2026-09-05T21:42:21.347380761Z","summary":{"from":"\"Alice\" <a@example.com>",
"to":["b@example.com"],"title":"Hello from tommy","snippet":"It works."}, ...}]
Drive a real protocol through a published port
The HTTP ingress is the easy half. This is the half that proves the container is not quietly listening on its own loopback — SMTP, on its own listener, from outside the container:
# ci: smtp
printf 'From: app@example.com\r\nTo: ops@example.com\r\nSubject: Sent over SMTP\r\n\r\nThe published port reached the SMTP listener.\r\n' > /tmp/tommy-mail.txt
curl -s --url smtp://127.0.0.1:1025 \
--mail-from app@example.com --mail-rcpt ops@example.com \
--upload-file /tmp/tommy-mail.txt
curl -s "http://127.0.0.1:8811/api/v1/events?plugin=mail&provider=smtp"
[{"id":"01a0738886ab0001431be100","plugin":"mail","provider":"smtp","type":"mail.message",
"summary":{"from":"app@example.com","to":["ops@example.com"],"title":"Sent over SMTP",
"snippet":"The published port reached the SMTP listener."}, ...}]
Fetch the AS2 certificate
AS2 needs a key pair, which tommy mints on first use and keeps on /data:
# ci: as2
curl -s http://127.0.0.1:8822/as2/certificate | openssl x509 -noout -subject -enddate
subject=O=tommy, CN=tommy AS2
notAfter=Sep 2 20:42:35 2036 GMT
The certificate a partner imports survives the container being recreated, as
long as the same /data volume is attached — the fingerprint at
http://127.0.0.1:8811/api/v1/as2/identity is unchanged after a
docker rm -f tommy and a fresh docker run with -v tommy-data:/data.
# ci: stop
docker rm -f tommy
Keeping S3 and Files data across restarts
/data above already survives a restart — that is how the AS2 identity keeps
its fingerprint. TOMMY_PERSIST puts a plugin's own state there too: today
that means the S3 catalog and object bytes, and the Files tree. Captured
events (mail, sms, chat, ...) are not persisted — they stay in memory for
the life of the process, TOMMY_PERSIST or not. That is a deferred decision
rather than a principle; docs/implementation-plan.md → Deferred: persisting
captured events says why. TOMMY_PERSIST is shorthand for [storage] backend = "filesystem", described in docs/contracts.md.
# ci: persist
docker volume create tommy-persist-demo
docker run -d --rm --name tommy-persist \
-p 8811:8811 -p 9000:9000 \
-e TOMMY_PERSIST=/data/tommy \
-v tommy-persist-demo:/data \
can3p/tommy:latest
Create a bucket and put an object in it — a plain curl, since tommy's S3
provider accepts an unsigned request same as any other credentials (see
plugins/s3/providers/http/README.md):
# ci: persist-write
curl -s -X PUT http://127.0.0.1:9000/persisted
curl -s -X PUT http://127.0.0.1:9000/persisted/hello.txt --data 'kept across restarts'
Recreate the container against the same volume. A plain restart would prove
nothing, since the process never stopped — this is docker rm -f and a fresh
docker run:
# ci: persist-restart
docker rm -f tommy-persist
docker run -d --rm --name tommy-persist \
-p 8811:8811 -p 9000:9000 \
-e TOMMY_PERSIST=/data/tommy \
-v tommy-persist-demo:/data \
can3p/tommy:latest
# ci: persist-check
curl -s http://127.0.0.1:9000/persisted/hello.txt
echo
curl -s "http://127.0.0.1:8811/api/v1/events?plugin=s3"
kept across restarts
[]
The object came back; the event that recorded its upload did not.
# ci: persist-down
docker rm -f tommy-persist
docker volume rm tommy-persist-demo
In compose, this is the commented TOMMY_PERSIST line in
docker-compose.yml — uncomment it to keep the
tommy-data volume's S3 and Files state across docker compose up/down.
Compose reuses that named volume across every up, so docker compose down
alone leaves it in place; docker compose down -v is what gives you a clean
slate again.
The same precedence as TOMMY_S3_BUCKETS applies: an explicit --persist (or
--storage SCOPE=BACKEND, for overriding one plugin or provider rather than
the whole default) on the command line wins over TOMMY_PERSIST, which wins
over whatever a mounted config's own [storage] says.
Create S3 buckets at startup
Set TOMMY_S3_BUCKETS when a compose stack needs buckets before the application
starts. The binary reads it directly, so the image's distroless base needs no
shell or entrypoint wrapper:
docker run -d --rm --name tommy-s3 \
-p 8811:8811 -p 9000:9000 \
-e TOMMY_S3_BUCKETS=media,exports \
can3p/tommy:latest
export AWS_ACCESS_KEY_ID=tommy AWS_SECRET_ACCESS_KEY=tommy AWS_DEFAULT_REGION=us-east-1
aws --endpoint-url http://127.0.0.1:9000 s3api head-bucket --bucket media
docker rm -f tommy-s3
The same value in compose is environment: { TOMMY_S3_BUCKETS: media,exports }.
Configured buckets produce no create events, and deleting one only removes it
until the next container start. Mount a config and set
[plugins.s3.providers.http] buckets = [...] instead when an environment
variable is not convenient; the environment wins when both are present.
Narrow it to one plugin
The image ships the repository's tommy.toml, so narrowing is one read-only
mount over that path and no flags to remember:
# ci: narrow
cat > /tmp/mail-only.toml <<'TOML'
# Only the mail plugin, and only its mailjet provider.
default_enabled = false
[plugins.mail]
enabled = true
[plugins.mail.providers.mailjet]
enabled = true
TOML
docker run -d --rm --name tommy-mail \
-p 8811:8811 -p 8822:8822 \
-v /tmp/mail-only.toml:/etc/tommy/tommy.toml:ro \
can3p/tommy:latest
Ask the container what that actually enabled — providers reads the same file
without starting anything:
# ci: narrow-providers
docker run --rm -v /tmp/mail-only.toml:/etc/tommy/tommy.toml:ro \
can3p/tommy:latest providers --config /etc/tommy/tommy.toml
Mail (mail)
Captures the email your application sends instead of delivering it, whether it went out through a vendor's HTTP API or plain SMTP. ...
mail/mailjet
Mailjet's transactional Send API v3.1: ...
POST /v3.1/send Accept a Mailjet v3.1 { "Messages": [...] } batch, ...
And the disabled providers are really gone — mailjet answers, SendGrid's route is not mounted at all:
# ci: narrow-check
curl -s -o /dev/null -w 'mailjet %{http_code}\n' http://127.0.0.1:8822/v3.1/send \
-u any:any -H 'Content-Type: application/json' \
-d '{"Messages":[{"From":{"Email":"a@example.com"},"To":[{"Email":"b@example.com"}],"Subject":"narrowed","TextPart":"hi"}]}'
curl -s -o /dev/null -w 'sendgrid %{http_code}\n' http://127.0.0.1:8822/v3/mail/send \
-H 'Authorization: Bearer x' -H 'Content-Type: application/json' -d '{}'
docker rm -f tommy-mail
mailjet 200
sendgrid 404
The single-plugin shortcuts work too, and need no file at all — the entrypoint
is the binary, so docker run … can3p/tommy mail is tommy mail. Remember that
overriding the command loses the image's --bind 0.0.0.0, so put it back:
# ci: shortcut
docker run -d --rm --name tommy-shortcut -p 8811:8811 -p 8822:8822 \
can3p/tommy:latest mail --bind 0.0.0.0 --enabled-providers mailjet
sleep 2
curl -s "http://127.0.0.1:8811/api/v1/plugins"
docker rm -f tommy-shortcut
The compose stack, and FTP
docker-compose.yml publishes every default port,
attaches the /data volume and mounts
docker/tommy.toml. It is what to paste into a stack of
your own:
# ci: compose-up
docker compose up -d
FTP is the one protocol a container gets wrong by default, so it is the one
worth driving. Upload a file and read it back with curl, which speaks FTP:
# ci: ftp
echo 'invoice 4711' > /tmp/invoice.txt
curl -s -T /tmp/invoice.txt --ftp-create-dirs ftp://tommy:secret@127.0.0.1:2121/orders/
curl -s ftp://tommy:secret@127.0.0.1:2121/orders/invoice.txt
curl -s "http://127.0.0.1:8811/api/v1/events?plugin=files"
invoice 4711
[{"plugin":"files","provider":"ftp","summary":{"from":"tommy","to":["/orders/invoice.txt"],
"title":"/orders/invoice.txt","snippet":"uploaded /orders/invoice.txt (13 B)"}, ...}]
Any credentials are accepted, as everywhere in tommy.
# ci: compose-down
docker compose down -v
How it works
Why gcr.io/distroless/static:nonroot
scratch is smaller and wrong: it has no CA certificates and no tzdata, both of
which a Go binary that makes outbound TLS calls and stamps events with a
timestamp wants. alpine's only real advantage over distroless is a shell for
docker exec, and tommy's whole debugging surface — the UI, the events API, the
SSE stream — is already reachable over the network, so the shell buys little and
brings a package manager and a libc with it. :nonroot means the image runs as
uid 65532 with no capability grants, which is possible only because every
default port is ≥ 1024 (a test in plugins/all holds that).
The bind trap
config.DefaultBind is 127.0.0.1. That is right for a binary on a laptop and
useless in a container: a published port never reaches a loopback listener. The
image's default command therefore carries --bind 0.0.0.0, and the default
itself is deliberately left alone — a fake that listens on every interface by
default is a worse default for the common case.
Two consequences worth knowing:
--bindreaches every listener, not only the HTTP ones. A provider's section inherits the top-level bind when it names none of its own, so SMTP, FTP, SFTP, TFTP, NFS, MLLP and the SNMP trap receiver all follow the flag. (This was not true until the image was built: the flag reached the three core listeners and nothing else, and the container published nine ports while answering on three.) A section that does set its ownbindstill wins, so a mounted config withbind = "127.0.0.1"under a provider will make that one listener unreachable from outside — the one edit to avoid.- Overriding
command:loses it.docker run … can3p/tommy mailreplaces the whole default command,--bind 0.0.0.0included, so pass it again.
The configuration story
The default command is:
serve --bind 0.0.0.0 --config /etc/tommy/tommy.toml --as2-cert-dir /data/as2
--config names the repository's own tommy.toml, shipped in the image. That
file is default-equivalent — TestRepoConfigIsDefaultEquivalent in
core/config loads it, applies defaults and compares against a bare config, so
the claim in its header is enforced rather than asserted — which is what makes
shipping it a no-op. What it buys is the mount: narrowing tommy is
-v ./tommy.toml:/etc/tommy/tommy.toml:ro, with nothing to remember about
flags.
The flags stay on the command line because cmd/serve.go loads --config
first and applies flags over it, and --bind additionally clears the three
core listeners' own binds. That is the whole reason the mount is safe: a config
copied from the repository example, bind = "127.0.0.1" still in it, cannot
silently make the container unreachable.
The alternative, rejected: leave --config off the default command and tell
people to add it when they mount. It loses --bind 0.0.0.0 the moment anyone
overrides the command, which puts the loopback trap back in front of exactly the
users who narrowed their configuration — the ones already doing something
deliberate.
/data, and what lives on it
/data is a declared VOLUME, owned by uid 65532. It holds what tommy
generates, never what it captures:
- the AS2 identity (
--as2-cert-dir /data/as2in the default command). AS2 mints a key pair on first use, and without this it would land beside the config file — a directory this user cannot write. A partner imports that certificate once; the volume is what keeps them from having to do it again after every restart. - the SFTP host key, if you point
host_key_pathat it asdocker/tommy.tomldoes. Without that the key is regenerated whenever the container is recreated, and ansftpclient that remembers the old one refuses to connect.
Captured events and payload bytes are in memory by design and are not on the volume: tommy is a catcher, not a store.
FTP passive mode
FTP is the one protocol here that hands the client an address to dial back, and
a container is exactly where that goes wrong. Two settings have to agree, and
both live in the mounted config
(docker/tommy.toml) because they are file-only
settings on tommy serve:
passive_portspins the range the data connection comes from. Unset, the OS picks any free port — which is not published, so every transfer hangs.docker-compose.ymlpublishes the same range.passive_hostis the address tommy tells the client to dial.127.0.0.1is right for a client on the docker host itself; a client anywhere else needs the host's own reachable address, and it is the one value tommy cannot work out for itself.
The ports
The image EXPOSEs the two core listeners (8811 for the UI and API, 8822 for
the ingress) plus every listener provider's default port, /udp marked where it
belongs. Rather than a table here that would go stale, ask the binary — it is
the same answer the Dockerfile and docker-compose.yml are held to by test:
docker run --rm can3p/tommy:latest providers --json |
jq -r '.[].providers[] | select(.listener) | "\(.port)/\(.network) \(.name)"'
Building and publishing
The Dockerfile is written for GoReleaser's dockers_v2 build context, which
stages each prebuilt binary under a directory named after its platform and adds
whatever extra_files lists. Two files besides the binaries have to be in that
context: LICENSE (MIT requires the notice to travel with every copy) and
tommy.toml. The local build above stages a context in the same shape, so there
is one Dockerfile and no second, divergent copy.