# swarm-cronjob: cron-style labels that Docker Swarm turns into scheduled services

> swarm-cronjob watches a Docker Swarm cluster through the Docker API, reads cron-style schedules from service labels, and creates the scheduled job runs itself, so a cluster does not need a second scheduler alongside it. It is a small Go program with a MIT licence and a container image, and it does exactly one thing, which is both its strength and the reason it will not fit a Kubernetes setup.

**crazy-max/swarm-cronjob** — Create jobs on a time-based schedule on Docker Swarm

- Repository: https://github.com/crazy-max/swarm-cronjob
- Website: https://crazymax.dev/swarm-cronjob/
- Stars: 888 · Forks: 77
- Language: Go
- License: MIT
- Published: 2026-10-20 · Updated: 2026-10-20 · Language: en
- Canonical page: https://hysenlabs.com/projects/crazy-max-swarm-cronjob

## The schedule lives on the service, not in a separate scheduler

The mechanism is unusual enough to be worth stating plainly before anything else. swarm-cronjob lets you run recurring jobs on Docker Swarm by defining cron-style schedules with service labels, and the labels are set when the service is created.

The consequence is that there is no second configuration file describing which jobs should run and when. The state is attached to the services themselves, using the Docker metadata mechanism that Swarm already has for labels, so a scheduled job and the thing it acts on are declared in the same place.

The tool then watches your services through the Docker API, keeps its configuration in sync automatically, and creates scheduled job runs across the cluster without requiring a separate scheduler.

That last clause is the whole pitch. The usual alternative is to run a scheduler alongside the cluster, and that scheduler has to know about the cluster, hold credentials, keep its own definition of what should run, and be reconciled with whatever else declares the same services. A label on the service removes the reconciliation, because the cluster is already the source of truth for what exists.

It also means the tool is reactive rather than authoritative. It discovers jobs by looking at the cluster, so a job exists exactly as long as the service carrying its label does. Removing the label or the service removes the job, and there is no orphan to clean up. The project documentation lives on crazymax.dev/swarm-cronjob, and the repository carries a mkdocs.yml and a docs directory, so the label format is documented rather than left to inference.

The project description is a single line: create jobs on a time-based schedule on Docker Swarm, and the topics list names cronjob, docker, docker-api, scheduler, swarm and swarm-mode.

## The dependency list tells you exactly what it talks to

The Go module file is short, and every direct dependency is there for a reason.

The module path is github.com/crazy-max/swarm-cronjob and it requires Go 1.26.0. The direct requirements are alecthomas/kong for command line parsing, docker/cli for the command line client libraries, moby/moby/api and moby/moby/client for the Docker API, go-viper/mapstructure for configuration decoding, pkg/errors, robfig/cron/v3 for cron expression parsing, rs/zerolog for structured logging, and stretchr/testify for tests.

Three of those are the interesting ones. moby/moby/client and moby/moby/api are the modern split Docker client packages, which means the tool talks to the daemon through the current API rather than through the old monolithic docker package. robfig/cron/v3 is the standard Go cron expression parser, which is what makes the cron-style schedules in the labels parseable. And go-viper/mapstructure is the decoding layer that turns the service labels into typed configuration, which is what makes the whole scheme work: a label is a flat string, and something has to interpret it.

The last two entries in the require block are worth a note, because they are development dependencies rather than runtime ones. stretchr/testify is the assertion library, and the second require block holds the indirect dependencies, the transitively pulled packages that include the Azure and Windows terminal handling, the containerd error definitions, the Docker credential helpers, the distribution reference parser, gRPC gateway components and the URI template library.

That dependency list is a description of the design. This is a small daemon that reads labels, parses cron expressions, and creates services. It does not contain a workflow engine, a job history store, a dashboard, or a retry policy.

The repository layout backs that up. There is a cmd directory, an internal directory, a hack directory, a test directory, a docs directory, a Dockerfile, a docker-bake.hcl, a Makefile, a .golangci.yml and a codecov.yml.

## Building it locally means running the vendor target

The Makefile in the repository is short, and it has a single meaningful target.

```bash
docker buildx bake --set "*.output=type=local,dest=$($@_TMP_OUT)" vendor
```

That line is the body of the vendor target, and the surrounding lines set up a temporary output directory with mktemp, run the bake with a local output type, remove the existing ./vendor directory, copy the produced files into place, and then clean up the temporary directory.

The reason for the dance is the vendor directory itself. The repository tree contains a vendor directory, and the Dockerfile sets the environment variable GOFLAGS to -mod=vendor, which tells the Go toolchain to build from the vendored packages rather than resolving them from a module proxy. Vendoring the dependencies means the build is reproducible and the container build does not need network access to a module proxy at build time, and the Makefile target is how that vendor tree is refreshed when a dependency changes.

The bake is driven by docker-bake.hcl, which is the BuildKit bake definition. Running the bake rather than a plain docker build is what allows several stages to be built and exported at once with a single local output path, and the same file is what a CI pipeline would use.

The Dockerfile itself is a modern multi-stage build and it is worth reading for what it says about the release process. The syntax directive pins the Dockerfile frontend version, and the build arguments set the Go version to 1.26, the Alpine version to 3.23 and the xx cross-compilation tool version to 1.9.0.

The version stage is where the version string comes from. It takes a GIT_REF argument, and a case statement strips a refs/tags/v prefix when the reference is a tag, and otherwise falls back to a git describe with a match on v followed by digits, a dirty suffix and the always flag. The result is written to a file that the build stage then reads.

The build stage runs the cross-compiler and verifies the result:

```dockerfile
  xx-go build -trimpath -ldflags "-s -w -X main.version=$(cat /tmp/.version)" -o /usr/bin/swarm-cronjob ./cmd
  xx-verify --static /usr/bin/swarm-cronjob
```

The -X main.version flag is how the version string reaches the binary, and the trimpath flag strips local filesystem paths from the binary, which is what makes the build reproducible. The static verification step then confirms the binary has no dynamic dependencies, which is the prerequisite for the scratch images that follow.

## A scratch image with no base operating system underneath

The end of the Dockerfile is where the size story becomes concrete. The binary is copied into a stage that starts FROM scratch, and there are separate stages for the Unix and Windows artefacts, with the Unix one reused for both the Darwin and Linux targets.

```dockerfile
FROM binary-unix AS binary-darwin
FROM binary-unix AS binary-linux
FROM binary-$TARGETOS AS binary
```

A scratch stage has no shell, no package manager, no libc and no configuration files. It contains exactly the binary and whatever you copy into it. That is only possible because the build sets CGO_ENABLED to 0, which means the Go binary is statically linked and does not need a C library at run time, and the xx-verify step in the build stage proves that before the artefact is published.

The image is therefore as small as a static Go binary can be, and it has essentially no attack surface from the base image, because there is no base image. For a tool that holds Docker API credentials, that is a meaningful property rather than a cosmetic one.

The test stage is set up separately and is worth noting because of what it does not assume. It sets CGO_ENABLED back to 1, installs gcc, the Linux headers and musl-dev, and runs the test suite with the race detector and atomic coverage:

```dockerfile
  go test -v -coverprofile=/tmp/coverage.txt -covermode=atomic -race ./...
```

CGO is needed for the race detector, which is why the test stage is not built with CGO_ENABLED=0 like the build stage. The coverage profile is then exported into its own scratch stage as a file, which is the shape a CI job needs to publish it, and the repository has a codecov.yml to consume it.

The build arguments are declared as ARG GO_VERSION, ALPINE_VERSION and XX_VERSION rather than hardcoded, which is the standard way to let a multi-arch bake override the toolchain for a specific target.

## A tool with a narrow job, and a project with a broad output

Two things about this repository are worth separating: what the tool does, and what the project around it does.

The tool creates scheduled job runs on Docker Swarm from cron-style service labels. It watches the cluster through the Docker API, keeps its configuration in sync, and creates the runs. There is no documentation in the README about job history, no retry policy, no dependency between jobs, and no notification on failure. It is a scheduler that knows how to translate a label into a service.

The project around it is much larger. The README badges point at a Docker image with pull counts, a documentation site built with MkDocs, a build workflow, codecov coverage, GitHub releases, a sponsor link and a PayPal donation link. The same author runs a related project, Diun, described in a tip block as a Docker image update notifier.

The maintenance record is healthy but not fast. The recent releases are v1.16.0 from 2026-04-23, v1.15.0 from 2025-12-31 and v1.14.0 from 2024-12-23, and the last push to the repository was on 2026-09-17 with the repository not archived. The version numbers are minor releases, not patch releases, and a tool whose behaviour depends on the Docker API is going to need a release whenever that API moves.

That is the dependency that matters here. The project depends on moby/moby/client and moby/moby/api at specific versions, and the versions in the module file are recent, with the client at 0.4.1 and the API package at 1.54.2, and docker/cli at 29.4.2. A Docker Engine upgrade can therefore break a swarm-cronjob deployment in a way that a self contained utility would not, and the fix is an upgrade of this tool rather than a configuration change.

## Where a label-driven scheduler is the wrong shape

Three boundaries rule it out, and they are all about the environment rather than the quality of the implementation.

The first is Kubernetes. The schedule is a Docker Swarm service label, and the tool talks to the Docker API to create the runs. If your orchestrator is Kubernetes, the equivalent primitive is a CronJob resource, which the platform already provides, and this tool offers nothing on top of it. Installing swarm-cronjob to manage Docker API credentials against a cluster you do not run would be a strange trade.

The second is the need for a real workflow. If a job must run after another job succeeds, must be retried on failure with backoff, must alert a human when it fails, or must be auditable after the fact, then you want a workflow engine, and a scheduler that creates services does not provide any of that. The absence of job history in the documentation is the tell.

The third is the operational surface. The tool needs Docker API access to the cluster, which means a credential with permission to create and update services, and that credential has to be managed and rotated like any other cluster secret. A label on a service is a fine place to declare intent, but the machinery that acts on it still needs standing authority over the cluster, and the more authority a helper needs, the more you should ask what it is doing with it.

The comparison with the built-in alternative is straightforward. Docker Swarm itself has no cron primitive, which is exactly the gap this tool fills, and it fills it without asking you to run and reconcile a second scheduler. If you are on Swarm and your jobs are genuinely periodic service runs, that is a good deal.

## Conclusion

swarm-cronjob fits a team running Docker Swarm in production that wants recurring work, such as backups or report generation, expressed as labels on the services that need it rather than as a separate scheduler with its own inventory. It is the wrong tool if your workloads run on Kubernetes, since the mechanism is a Docker Swarm service label, and it is the wrong tool if you need a general workflow engine with dependencies and retries. Before adopting it, confirm your cluster is in Swarm mode, read the documentation on the service label format rather than guessing, and check the release history, because the recent tags are v1.16.0 from 2026-04-23, v1.15.0 from 2025-12-31 and v1.14.0 from 2024-12-23, and the last push was on 2026-09-17.

## FAQ

### What is swarm-cronjob and how does it schedule jobs?

It is a Go program that runs recurring jobs on Docker Swarm by defining cron-style schedules with service labels. It watches services through the Docker API, keeps its configuration in sync automatically, and creates scheduled job runs across the cluster without a separate scheduler.

### Does swarm-cronjob work with Kubernetes?

No. The schedules are Docker Swarm service labels and the tool creates runs through the Docker API, so it targets Swarm mode clusters. On Kubernetes the platform's own CronJob resource is the equivalent primitive.

### How do I build swarm-cronjob from source?

The Makefile has a vendor target that runs docker buildx bake with a local output type and copies the produced vendor tree into the repository, and the Dockerfile cross-compiles with xx-go, stamping the version with a -X main.version flag and verifying the binary is static. The version comes from a git describe or from a tag reference.

### Why does the swarm-cronjob Docker image use a scratch base?

The build sets CGO_ENABLED to 0 so the Go binary is statically linked, and the xx-verify step confirms it has no dynamic dependencies before the artefact is copied into a FROM scratch stage. The result is an image with no operating system underneath it, which keeps the footprint small and the attack surface minimal.

### What licence is swarm-cronjob released under and how recent is it?

It is MIT licensed, with the licence text in the LICENSE file. The recent releases are v1.16.0 from 2026-04-23, v1.15.0 from 2025-12-31 and v1.14.0 from 2024-12-23, and the last push to the repository was on 2026-09-17.

## Sources

- [crazy-max/swarm-cronjob on GitHub](https://github.com/crazy-max/swarm-cronjob)
- [License: MIT](https://github.com/crazy-max/swarm-cronjob/blob/master/LICENSE)
- [Project website](https://crazymax.dev/swarm-cronjob/)
- [README](https://github.com/crazy-max/swarm-cronjob/blob/master/README.md)
- [Releases](https://github.com/crazy-max/swarm-cronjob/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/crazy-max-swarm-cronjob
