Skip to main content
This page captures the reasoning behind the main architectural decisions in sitectl. Understanding the “why” makes it easier to contribute in a way that stays consistent with the project’s direction.

Why sitectl does not use Docker Contexts alone

While Docker’s native context feature handles basic Docker daemon connections, sitectl is designed for projects and adds:

Remote operations

SFTP file operations and clearer SSH error handling beyond what Docker’s own context system exposes.

Container utilities

General helpers to resolve service names to containers, extract secrets and env vars for exec commands, and inspect container network details.

-first design

Automatically sets the equivalent of DOCKER_HOST, COMPOSE_PROJECT_NAME, COMPOSE_FILE, and COMPOSE_ENV_FILES from the active sitectl context.

Plugin model

Plugins extend sitectl without requiring changes to the core binary. The core binary discovers plugins by name convention (sitectl-<plugin> on $PATH) and delegates commands to them. This means:
  • Stack-specific logic stays in the plugin, not in core
  • Plugins can be installed and upgraded independently within their advertised core SDK/protocol compatibility range
  • Plugins can include other plugins. ISLE includes Drupal, so operators of ISLE sites get Drupal commands automatically
See Plugin hierarchy for how dispatch works at the code level. Runtime fan-out uses a private JSON RPC entrypoint, __sitectl-rpc, instead of a hidden command per capability. A single transport command gives core sitectl a central enforcement point for protocol versioning, stdout/stderr behavior, structured errors, and SDK dispatch. Plugin authors still implement small typed handlers through the SDK. The RPC contract is versioned in lockstep between core and plugins. Request envelopes, response envelopes, params, and result payloads use explicit lower snake_case JSON tags, and typed request builders in pkg/plugin should be preferred over hand-written JSON maps.

Compose lifecycle metadata

Plugin create definitions are the shared contract between sitectl create and local sitectl compose up. A CreateSpec declares the template repository, init commands, build commands, up/down/rollout commands, init artifacts, and image metadata for a Compose-backed project. Core sitectl create uses that metadata to scaffold a context. Core sitectl compose up uses the same metadata for command-scoped first-run reconcile on local plugin-owned contexts. The reconcile step can run missing init and build work before starting Compose, but it remains a synchronous CLI workflow, not a background controller. Core v1 keeps the context boundary intact during deploy as well: context-selected Compose and environment files are injected into recognized plugin rollout commands for local and remote contexts. sitectl deploy --ref can fetch an exact advertised remote ref into a detached checkout without moving a downstream branch, while ordinary branch updates remain clean-tree, fast-forward-only operations. See Compose reconcile contract for the detailed authoring contract and the boundary around Kubernetes-style terminology.

Template provenance and downstream evolution

Released application plugins select stable template refs rather than moving default branches. During create, sitectl resolves the cloned template commit before removing its Git history, initializes a new downstream repository, and atomically writes .libops/template.lock.yaml. The lock can retain the source repository and commit, sitectl/plugin build identities, the SHA-256 digest of the source-owned .libops/template-contract.yaml, and a component-defaults revision. Ownership is deliberately asymmetric:
  • the upstream template owns .libops/template-contract.yaml and may publish a component-defaults revision in that contract or in .libops/component-defaults.revision;
  • the create operation owns .libops/template.lock.yaml; a source template must not ship that downstream file; and
  • the site owner owns the fresh Git history and every later merge, customization, migration, and deployment decision.
The lock is retained provenance, not an attestation, a desired-state store, or an automatic update mechanism. Future component releases can use its contract revision to reason about the starting defaults, but they must still preserve downstream-owned fields and require an explicit reviewed transition. Template URLs containing inline credentials, query strings, or fragments are rejected; private sources should use an SSH agent or Git credential helper.

Application and Image Ownership

Each application tree has one source owner. Buildkit owns verified upstream releases for OJS, Omeka, and ArchivesSpace and publishes images whose tags identify the bundled upstream application version; their downstream templates extend those images with tracked customizations. Drupal, ISLE, and WordPress keep Composer manifests, lockfiles, and application code in the downstream checkout; their Buildkit image tags identify the runtime line, while the downstream Composer lockfile identifies the application dependency set. A published runtime image must not introduce a competing application tree. Cross-domain application features follow the same boundary. An application plugin may coordinate narrowly owned Compose, Drupal, and Composer-manifest changes as one reviewable component, but it does not generate the downstream lock file, import live configuration, deploy, or backfill data. The ISLE feature-bundle contract shows this split for mergepdf and hOCR search. Tracked Compose dependencies use readable tags plus immutable digests. Buildable application services keep BASE_IMAGE as an overrideable build argument so a local context can test another version without replacing the downstream build contract. App plugins mirror that choice in CreateSpec.Images and rebuild policies. Inside the published images, s6-overlay owns process/setup ordering and confd turns environment and secret inputs into dependent-service configuration. Templates should expose supported settings through tracked Compose environment values and components instead of copying generated nginx, PHP-FPM, or application config into another owner layer. Ingress components must also describe the application runtime they configure. PHP/nginx applications may use the shared upload, timeout, and trusted-proxy environment mapping. A non-PHP runtime opts out of those defaults and supplies only mappings its backend understands, while retaining generic Traefik router and entrypoint changes. ArchivesSpace follows this non-PHP contract and removes stale PHP/nginx and PUBLIC_URL values from its application service. The boundary also controls development mounts. A plugin registers dev-mode only for downstream-owned trees or explicitly named extension paths. It must not mount an empty broad directory over plugins, modules, themes, locales, or stylesheets bundled in a versioned application image.

One-shot Dependency Health

Database root credentials belong to a dedicated database-init service, not the long-running app. The app receives a scoped account and requires the initializer through Compose’s service_completed_successfully condition. Core healthcheck reads the effective Compose model before accepting a stopped container. Only a defined, required dependency that is exactly exited (0) qualifies; optional/arbitrary jobs and failed, dead, or missing containers remain unhealthy. This makes one-shot success part of the tracked application contract instead of a name-based exception.

Service component ownership

Reusable infrastructure services that are self-contained and cross-cutting belong in sitectl core, not in one plugin per service and not copied into every application plugin. Traefik, MariaDB, Solr, Valkey, and Memcached are core service command namespaces because many application stacks use them and the operational behavior is generic. Those services may still run as standalone Compose projects. Core owns the shared command surface and helpers, while application compose projects own target-specific wiring such as dependencies, environment variables, routes, and app-specific volume mounts. See Service components for the full architecture.

SDK runner interfaces

Where a core sitectl command needs plugin-specific behavior, the SDK defines a typed runner interface. The plugin implements the interface, registers it, and the SDK exposes it through the single private __sitectl-rpc entrypoint. This gives plugin authors a structured, type-safe extension point rather than requiring them to write raw transport code. Current runner interfaces: sitectl validate, sitectl healthcheck, and sitectl verify return structured check results instead of rendering directly. For validate, core runs its own validators first, then invokes the plugin’s validate.run method and merges the returned results. For healthcheck, core checks Compose service runtime state first, then merges plugin runtime checks. For verify, core delegates behavioral verification to the active plugin and renders the returned results. IngressRouteProvider is a lightweight provider rather than a command runner. Plugins use it to declare the public routes they own, including secondary URLs such as an API, OAI endpoint, Fedora endpoint, or IIIF route. Core resolves those route descriptors through Traefik and local port bindings so stats and debug report the same URLs a healthcheck would use. Runner diagnostics, progress, and prompts should write to stderr. Stdout is captured by the RPC layer for the response envelope and may not be displayed directly by the caller.

Component model

The component model was designed to solve two problems:
  1. Initial setup: make it easy to start a site with the right capabilities enabled, without manually editing files
  2. Incremental adoption: existing sites can adopt new upstream capabilities by turning a component on, rather than hand-editing files and hoping nothing breaks
Each component’s DefaultState is desired state, not merely display metadata. During create, the application plugin resolves and applies every registered component decision, including defaults that do not prompt. A default-on feature bundle can therefore add or normalize the fields and files it owns even when the operator supplies no component flag. Explicit create flags override those defaults, and the generated recreate command records the resolved choices. The top-level sitectl set and sitectl converge commands are the operator-facing surface for the component model. sitectl component describe, sitectl component set, and sitectl component reconcile remain as lower-level commands backed by the same __sitectl-rpc protocol methods. See Component development for how to define a new component.

Why , not Kubernetes

Though isn’t designed for massive-scale orchestration, the applications hosted by most institutions rarely require more than modest scaling. The real advantage of is the developer experience. Because the same orchestration runs in both development and production with only minor environment-specific changes, you can reliably mirror production on your local machine. This gives you deployment safety long before your CI pipeline runs a single test. We could have spent resources building Kubernetes operators for various LAC-GLAM stacks instead of creating sitectl. But sitectl was a deliberate choice: it empowers institutions to adopt open-source projects without the hurdle of hiring a Kubernetes administrator or absorbing the heavy operational overhead of a Kubernetes cluster. The goal was to let institutions adopt open-source software without being blocked by infrastructure complexity.