Author: ttpears

  • The Pipeline Is Executable Documentation

    A CI pipeline is often treated as a build script with a web interface. I find it more useful to treat it as executable documentation for how a change becomes a release.

    The order tells a story

    Linting, unit tests, integration checks, packaging, promotion, and deployment should appear in an order that reflects the project’s confidence model. A person reading the pipeline should be able to see which risks are checked early and which actions require a deliberate boundary.

    Names are part of the interface

    Stages and jobs should describe intent. “Run script 4” is difficult to review; “validate configuration” tells the next person what the job is protecting. Clear names also improve agent-assisted troubleshooting because the pipeline exposes useful vocabulary instead of internal shorthand.

    Failures should teach

    A failed job is part of the documentation too. Preserve the relevant output, point to the next diagnostic step, and distinguish a test failure from an environment failure. A red pipeline that only says “exit code 1” has recorded an event without recording knowledge.

    Keep values out of the story

    The pipeline can document the shape of a deployment with placeholders such as RELEASE_ID, DEPLOY_ENV, and HEALTHCHECK_URL. Real values belong in protected configuration. This keeps the workflow reviewable without turning its documentation into a map of private infrastructure.

    Review the path like code

    Pipeline changes can alter production behavior even when application code is untouched. Review them for permissions, artifact provenance, retry behavior, cleanup, and rollback. The delivery system is software; it deserves the same care as the service it delivers.

    When the pipeline explains the system and runs the system, it becomes a useful shared contract between developers, operators, and automation.


    Current as of September 2026. Identifiers, URLs, credentials, and environment details are placeholders.

  • Fast Internet Does Not Fix a Bad Network

    Fiber changed the ceiling on my internet connection, but it did not make the network simpler. Once local services, remote access, automation, and multiple clients share the same link, the interesting problems are still local: names, routes, failure modes, and boundaries.

    Bandwidth is not topology

    A fast uplink does not tell a client where a service lives or which path traffic should take. Local DNS, stable names, and deliberate network segments remain useful even when the external connection is faster than anything the household can saturate.

    Local services should have local answers

    Home Assistant, dashboards, containers, and management interfaces should not depend unnecessarily on an external resolver. Keeping internal names local improves latency, preserves useful behavior during an upstream outage, and makes certificates and reverse proxies easier to reason about.

    Remote access is another boundary

    A tunnel or reverse proxy can make a service reachable, but reachability is not the same as authorization. Decide which services need remote access, which should remain local, and where authentication belongs. A simple inventory prevents “temporarily exposed” from becoming a permanent architecture.

    Design for partial failure

    The internet connection can be healthy while local DNS is broken. A resolver can work while a wireless access point fails. Home automation can be reachable while one device is offline. Treat those as separate dependencies and the symptoms become much easier to interpret.

    The result is a network that feels calm when it is working because its important relationships are explicit. More bandwidth helps, but it is not a substitute for knowing what talks to what.


    Current as of September 2026. Addresses, providers, device names, and access details are intentionally omitted.

  • Documentation Needs an Interface

    Documentation becomes part of the system as soon as people depend on it to deploy, troubleshoot, or recover a service. At that point, a page hidden behind a browser-only workflow is a weak integration point. The documentation needs an interface that tools can use too.

    Structure beats a wall of text

    Books, chapters, pages, categories, links, and revision history are not decorative metadata. They give a person or an automated tool a way to find the relevant piece without reading everything. A stable structure also makes broken references easier to detect.

    Search is an operational feature

    When an incident is underway, the useful question is usually not “where is the documentation?” It is “what is the current procedure for this symptom?” Search, summaries, and links to related pages reduce the time spent navigating the documentation system itself.

    Read access should be easy to scope

    Most operational lookups do not need write access. A read-only integration can search pages, inspect revisions, and retrieve links while keeping mutations behind a separate permission boundary. That makes it safer to connect documentation to assistants, scripts, and other tools.

    Writes need history

    When a tool can update documentation, the change should remain attributable and reversible. Preview the intended edit where possible, preserve revisions, and make the result clear enough for a human reviewer to recognize what changed.

    Keep the interface boring

    Stable identifiers, pagination, explicit errors, and predictable response shapes are more valuable than a clever abstraction. They let clients make small requests and recover when a page is missing, a permission changes, or a service is temporarily unavailable.

    The point of a documentation API is not to replace writing. It is to make the writing available at the moment a person or a system needs it, with enough structure and history to be trusted.


    Current as of September 2026. Examples use placeholders and omit private documentation URLs, credentials, and deployment details.

  • The Distribution Is Part of the Constraint

    I use Debian in one environment and Arch in another. The useful lesson is not that one distribution wins. It is that a workflow should survive a distribution boundary without depending on muscle memory or an undocumented package name.

    Keep the intent stable

    The intent is usually straightforward: install a tool, run a test, inspect a service, build a container, or troubleshoot a network path. The command and package version may differ, but the desired result should remain clear.

    Writing down the intent makes translation easier. “Install package X” is less durable than “provide the compiler and headers required by the build.” The former is a command; the latter is a dependency.

    Use the distribution as a constraint

    Debian’s conservative release model and Arch’s rolling model encourage different habits. One may emphasize long-lived stability; the other may expose changes sooner. Neither removes the need to read release notes, test upgrades, and understand what a service actually depends on.

    When a project needs a consistent runtime, I make that boundary explicit with a container or development container. That reduces accidental drift while leaving the host responsible for the things the host actually owns.

    Automate the boring translation

    Configuration management is useful here because it can express packages, users, files, and services as desired state. The state still needs platform-specific branches where the platforms genuinely differ, but those differences become visible instead of being hidden in a person’s shell history.

    Test the first-run experience

    A setup that only works on a well-used workstation is not a setup. Start from a clean environment occasionally and follow the documented path. Missing dependencies, stale assumptions, and interactive prompts show up quickly.

    The goal is not to erase the character of each distribution. It is to make the important parts of the workflow portable, explicit, and recoverable.


    Current as of September 2026. Commands, hosts, package names, and environment details are intentionally generalized.

  • Configuration Is Not a Secret Store

    Configuration management makes a system easier to reproduce, but it is not automatically a safe place to put every value the system needs. Public state, environment-specific settings, and secrets have different lifecycles and should be handled accordingly.

    Describe the shape openly

    A Salt state can describe that a service needs a configuration file, a user, a directory, and a running process. That structure is useful to review and test. The secret value inside the file is a different concern and should not be mixed into the state just because the state owns the file.

    Use indirection deliberately

    Placeholders make the boundary visible: DATABASE_PASSWORD, API_TOKEN, or SMTP_RELAY can identify required inputs without revealing them. At deployment time, the values can come from an encrypted pillar, a protected variable store, or another controlled secret source.

    The important property is not the product name. It is that ordinary reviews can inspect the configuration shape without granting access to the value.

    Fail closed and fail clearly

    A missing secret should stop the deployment with a useful error. Substituting an empty value or a development default can produce a deployment that looks successful while failing later in a less obvious place.

    Rotate without rewriting the state

    Secrets change more often than service topology. If rotating a credential requires editing the state that describes the whole machine, the boundaries are too tightly coupled. Keep the reference stable and replace the protected value through its intended channel.

    Verify what was rendered

    It is worth checking permissions, ownership, and service reload behavior after rendering a secret-backed file. Do not print the secret to logs while doing so. A deployment system should prove that the file exists and is protected, not echo its contents as evidence.

    SaltStack is strongest when it makes desired state boring and visible. Keeping secret material behind an explicit boundary lets it do that without turning the configuration repository into a second password vault.


    Current as of September 2026. Values, paths, hosts, and credentials in this article are placeholders.

  • Make Deployments Boring

    The best deployment is usually the one nobody talks about afterward. It built the intended artifact, applied the intended configuration, passed its checks, and left enough evidence to explain what happened.

    Separate the three questions

    A deployment pipeline often becomes confusing because it tries to answer several questions at once:

    • Did the source produce a valid artifact?
    • Is the target configured correctly?
    • Did this release become active?

    GitLab CI/CD is a natural place to answer the first question. SaltStack is a natural place to answer the second. The release step should connect the two without hiding either result.

    Make the release identifiable

    Every deployed version should have an identifier that appears in the pipeline, the artifact, and the running service. It can be a commit, build number, or release label. The exact format matters less than being able to map a running process back to the change that produced it.

    Converge before switching

    Configuration should be applied and checked before traffic or a worker is switched to the new release. This catches missing packages, permissions, directories, and service settings while the old version can still provide a recovery path.

    Rollback should be a known operation

    “Deploy the previous version” is not a rollback plan unless the previous artifact and compatible configuration are still available. Keep the recovery action explicit, test it occasionally, and make sure the operator can tell whether the rollback completed.

    Measure the handoff

    Useful deployment logs show the transition between systems: which pipeline produced the release, which configuration revision was applied, which target accepted it, and what health check passed afterward. That small chain of evidence makes routine work auditable without making it cumbersome.

    Boring deployments are not accidental. They come from giving each layer a clear responsibility and making the boundaries visible when something goes wrong.


    Current as of September 2026. All names, identifiers, hosts, and environment values in this discussion are placeholders.

  • Agents Need Small Tools

    An agent is only as useful as the tools around it. A broad tool that can “manage the system” sounds convenient, but it creates a large permission boundary and makes mistakes difficult to inspect. I have had better results building small interfaces around the operations people actually need.

    One tool, one useful decision

    A good tool gives the agent enough context to make one decision and enough structure to make the result predictable. Searching a documentation system, listing an issue, reading a file, or querying a known data source are all easier to reason about than a single endpoint with dozens of unrelated modes.

    The tool description is part of the interface. Names, parameters, return shapes, and failure messages affect what the agent will attempt. Ambiguity in the schema becomes ambiguity in the operation.

    Read and write should feel different

    Read operations are easier to test and safer to expose. Writes need stronger boundaries: explicit authorization, useful previews, validation before mutation, and a result that says what changed. Making write access opt-in is not friction for its own sake; it gives the operator a deliberate control point.

    Compact results improve judgment

    Returning an entire object graph is rarely helpful. It consumes context while hiding the fields that matter. Compact responses with stable identifiers and links let the agent decide what to inspect next without turning every request into a data-export job.

    Transport changes the threat model

    A local process and a shared HTTP service are not interchangeable deployments. The latter needs authentication, host and origin controls, request limits, and a clear model for whose credentials are being used. The transport is part of the security design, not a packaging detail.

    Make failure ordinary

    Tools should fail with messages that explain the next safe action. A missing permission, invalid identifier, unavailable service, and validation failure are different conditions. When they look the same, an agent may retry the wrong thing or invent a workaround.

    The practical pattern is simple: narrow tools, explicit permissions, small responses, and failures that remain legible. That gives an agent room to be useful without asking the operator to trust an opaque automation layer.


    Current as of September 2026. Examples use placeholders and omit private endpoints, credentials, and deployment details.

  • A Dev Container Is a Contract with the Project

    A development container is useful when it removes setup drift without pretending that development and production are the same thing. The goal is not to put every concern in one image. The goal is to give contributors a dependable place to build and test the application.

    Reproduce the tools that matter

    The container should define the language runtime, system packages, command-line tools, and editor-facing integrations that the project actually needs. It should also make the normal commands obvious: install dependencies, run the tests, start a local service, and inspect the logs.

    That list is more valuable than a long image description. A new contributor should be able to open the project and answer “what do I run first?” without reverse-engineering a workstation that took months to accumulate.

    Keep source and state separate

    Source code belongs in the workspace. Generated dependencies, caches, databases, and temporary state should have deliberate storage rules. Mixing them together makes rebuilds slow and creates the illusion that a successful local run is reproducible.

    Volumes and bind mounts are tools, not defaults. Each one should have a reason, a lifecycle, and a cleanup story.

    Do not copy production blindly

    A development container often needs compilers, debuggers, test runners, and interactive tools that should never be shipped. A production image should be smaller and narrower. Sharing a base image can reduce drift, but the final images still have different jobs.

    Make the container disposable

    If deleting and rebuilding the container loses something important, that state needs to be moved into an explicit service or fixture. Disposable environments expose hidden dependencies early. That is uncomfortable, but much cheaper than discovering them during a deployment.

    Test the documented path

    The dev-container configuration is part of the project interface. Test it in CI when practical, or at least rebuild it periodically from a clean checkout. An environment that only works because one developer already has the right cache is not a development environment; it is an undocumented machine.

    The best dev container is not the most elaborate one. It is the one that shortens the path from checkout to a meaningful test while keeping production concerns explicit.


    Current as of September 2026. Examples use placeholders and omit environment-specific paths, credentials, and service names.

  • GitLab Builds the Change; Salt Makes It Real

    There is a useful division of labor between a CI/CD system and configuration management. GitLab CI/CD can build, test, and promote a change. SaltStack can make a machine match the state that the change describes. Keeping those jobs distinct makes both systems easier to understand.

    Let the pipeline prove the change

    A pipeline should answer whether a change is fit to move forward. That usually means linting, unit tests, integration tests, image builds, and whatever checks are specific to the service. It should also produce an artifact that can be identified later—not just “whatever was on the branch at the time.”

    Deployment steps should be explicit about which environment they target. I use placeholders such as APP_ENV, DEPLOY_TARGET, and RELEASE_ID in examples because the names and values belong in the deployment system, not in a public article or a repository.

    Let Salt describe the destination

    SaltStack is a good fit for the machine-side work: packages, users, directories, service configuration, permissions, and running services. A state should describe the result we want, not a sequence of lucky shell commands that happened to work once.

    That distinction matters when a deployment is repeated. A state that can be applied again without damaging an already-correct system is easier to recover, easier to review, and much less stressful when the first run is interrupted.

    Keep secrets out of the artifact

    Build output should not become a secret distribution mechanism. Credentials, tokens, and environment-specific values should come from the appropriate protected store and be rendered only where they are needed. Public examples should use placeholders, and the application should fail clearly when a required value is absent.

    Use promotion as a control point

    Not every successful build should immediately change production. Promotion provides a useful boundary for review, approval, and a final check that the artifact and configuration belong together. The pipeline can carry the release identifier; Salt can verify that the target state is compatible before applying it.

    Test the awkward paths

    The happy path is the least interesting part of deployment. Test a missing variable, an unavailable package mirror, a service that will not restart, a partially completed run, and a second application of the same state. Those tests reveal whether the system is actually recoverable or merely impressive when everything is healthy.

    The result is a practical boundary: GitLab CI/CD provides the evidence and promotion flow, while SaltStack provides repeatable convergence on the destination. Neither tool has to pretend to be the other.


    Current as of September 2026. Examples use placeholders intentionally; deployment names, hosts, credentials, and environment details should stay in the systems that manage them.

  • When a Cron Job Takes Four Hours

    Scheduled work is easy to underestimate. A cron entry can be one line long while the job behind it takes hours, touches a large amount of data, and competes with several other jobs for the same application and database.

    That is the problem behind my mautic-cron-scripts project. The useful part is not a clever command. It is turning a pile of periodic tasks into something that can be operated: predictable enough to schedule, visible enough to debug, and constrained enough that one slow run does not create a backlog of overlapping work.

    Start with the work, not the schedule

    “Run every five minutes” is not a useful definition if a normal execution takes an hour. The schedule needs to reflect the work being done: segment updates, email processing, cleanup, and other application maintenance have different runtimes and different failure modes.

    I prefer to begin by measuring each task. Record when it starts, when it finishes, whether it succeeded, and enough output to explain a failure. Once those measurements exist, the schedule can be chosen from evidence instead of habit.

    Long-running jobs need boundaries

    A job that can run indefinitely is difficult to reason about. Batch limits, explicit time windows, and sensible cleanup policies give the process a boundary. They also make it possible to resume work without treating every interruption as a disaster.

    Overlap is another boundary problem. Two copies of a heavy update can make both copies slower, increase database pressure, and leave operators unsure which result is authoritative. A lock or an equivalent single-run guard is a small addition that prevents a surprisingly large class of incidents.

    Make the logs answer operational questions

    Application logs often tell you what happened inside the application. A cron wrapper should answer the questions around it:

    • Which task ran?
    • When did it start and finish?
    • Which environment was it running against?
    • Did it exit successfully?
    • How much work did it attempt?

    That context turns “the queue is behind” into a useful investigation. It also makes a handoff possible; another person does not need to reconstruct the entire scheduling system from scattered crontabs.

    Be deliberate about where a job runs

    In a multi-server setup, a scheduled task should have an explicit home. A primary-host check, environment-specific configuration, or scheduler with a clear ownership model prevents the same work from running everywhere just because the code was deployed everywhere.

    This is especially important for jobs that send messages or mutate shared state. “It ran” is not sufficient. It ran on the intended host, against the intended environment, with the intended credentials and limits.

    Deployment is part of the job

    Small operational tools deserve a repeatable installation path. The scripts should have a known location, controlled ownership, writable log directories, a cron deployment mechanism, and log rotation. Without those pieces, a script can work perfectly in a terminal and still fail as a service.

    The broader lesson is portable: scheduled automation is production software when it moves data, sends email, or changes an application. Give it configuration, logs, failure handling, and a way to verify that the deployed version is the version you meant to run.


    Current as of September 2026. The examples here are intentionally application- and environment-agnostic; the implementation is available in mautic-cron-scripts.