The "one-man army" DevOps repository "pattern"

One way to run production without leaning on a full PaaS or a big cloud control plane is to keep a single Git repository that is only about operations on your own servers: Docker Compose stacks, nginx samples, deployment workflows, and shared scripts. Application repositories stay in charge of lint, test, build, and pushing images (often to a managed registry such as AWS ECR). The operations repo then owns what runs on the machine and how it is rolled forward.

Hosts clone that repo to a fixed path (for example /opt/devops) and use a self-hosted GitHub Actions runner so pipeline steps can run next to Docker - pull images, write env files, execute a small shell entrypoint - instead of trying to drive everything from GitHub-hosted runners alone. That is a different shape than "connect the repo to Vercel" or "terraform apply and let a managed orchestrator roll out."

The sections below generalize this approach, but they also include one concrete reference - folder tree and file snippets from a real central DevOps repository (ph-devops) so you can copy shapes mostly as-is after renaming hosts, images, and secrets.

Repository layout (example structure)

A concrete layout might look like this:

  • .github - GitHub Actions workflows (for example a deploy-service.yml triggered by workflow_dispatch) and any mapping from inputs to target host and service paths.
  • Per-host directories (names like vps-a / vps-b, or numbered hosts) - Under each, one folder per service with at least:
    • docker-compose.yml - Stack definition; healthchecks when possible.
    • run.sh - Invokes docker compose up with the flags and image tags your pipeline passes in.
    • Optionally healthcheck.js - For Node-based APIs where curl / wget checks are not enough - a tiny script using fetch against an HTTP health endpoint.
  • nginx - Sample virtual host files for reverse proxies on the box.
  • scripts - Shared automation - often shell for on-server steps and Python for CI glue: resolving which secrets apply, validating env, writing .env files from a manifest.
  • Extras - Folders for dynamic DNS configs, or sample GitHub settings - sometimes kept even when only partially used, for documentation or future use.

Sample folder structure

Collapsed view of the same repository - each *`ph-vps-** directory repeats the per-service pattern (docker-compose.yml,run.sh, optionalhealthcheck.js`) under one folder per stack:

ph-devops/
├── README.md
├── .github/workflows/          # e.g. deploy-service.yml, deploy-files.yml
├── scripts/python/             # deploy.json resolution + Bitwarden (see next section)
├── nginx/
├── ddns/                       # optional DynamicDNS assets
├── github/                     # optional OSS template samples
├── ph-vps-1/
│   ├── deploy.json
│   ├── README.md
│   └── homepage/               # example service: compose + run.sh + healthcheck.js
└── ph-vps-2/
    ├── deploy.json
    └── sonarqube/              # example service on second host

deploy.json, scripts/python, and Bitwarden (secrets pipeline)

In ph-devops, plaintext secrets do not live in Git. Each server has its own deploy.json next to ph-vps-1 or ph-vps-2. That file drives the Python steps in scripts/python/ and one BW_ACCESS_TOKEN (GitHub Actions secret) talking to Bitwarden Secrets Manager.

What deploy.json declares

  1. bitwarden - A map from environment variable names (what the workflow and Compose will use later) to Bitwarden secret IDs (UUIDs). These are references only, not secret values - for example keys like AWS_ACCESS_KEY_ID, AWS_ECR_REGISTRY, or a blob like *`ENVVARS** intended for an entire.env` file body.

  2. services - One entry per deployable application name. For each service:

    • vars - Which keys from the bitwarden map must be fetched when deploying this service (typically AWS credentials, registry host, plus any app-specific entries).
    • files (optional) - A list of { "var": "<name>", "uri": "/absolute/path/on/server" }. After fetch, the value of var is written to uri (for example .../app.env) so docker-compose can use env_file:.

Cross-checks: resolve-service-secrets.py ensures every name listed under services.<name>.vars and files[].var exists in the top-level bitwarden map, so typos fail early.

What each Python script does (in order)

Source files are not included, but the gist of it is explained.

Script Role
resolve-dispatch-service.py Reads workflow input like ph-vps-1/homepage, splits into server and service name, writes GitHub Actions outputs so later jobs know which deploy.json to use.
resolve-service-secrets.py Loads <workspace>/<server>/deploy.json, validates the service exists, builds a Bitwarden fetch spec (lines shaped like secret_id > VAR_NAME for every vars entry), and exports files_json / vars_json plus flags has_secrets, has_files, has_vars.
load-bitwarden-secrets.py Downloads and runs Bitwarden's sm-action binary with BITWARDEN_SECRETS_SPEC and BW_ACCESS_TOKEN, which injects fetched values into the job environment so AWS_ACCESS_KEY_ID and friends are set before the next steps. Emits ::add-mask:: for those values in logs.
validate-runtime-env.py Confirms every name in vars_json is present in the environment (so a failed Bitwarden fetch stops the deploy before run.sh).
write-env-files.py For each item in files_json, reads the corresponding env var and writes uri on disk with mode 0600 (typical for app.env consumed by Compose).

After that chain, normal Actions steps run - configure-aws-credentials and amazon-ecr-login use the AWS_* variables that Bitwarden populated, then cd /opt/devops/... and ./run.sh.

Contract in one sentence

deploy.json names which Bitwarden secrets each service needs and optionally where to materialize file-backed secrets; scripts/python turns that manifest into fetches, validation, and on-disk .env files, so the self-hosted deploy only needs one long-lived token BW_ACCESS_TOKEN in GitHub plus the UUID map in repo.

Snippets you can adapt

Generalized from ph-vps-1/, and .github/workflows/deploy-service.yml. Swap registry names, images, ports, and paths for your stack.

docker-compose.yml

Multi-service pattern: versioned images ${REGISTRY}/...:latest-${VERSION}, curl health for one service, node ./healthcheck.js for a Node API.

services:
  web:
    container_name: my-web
    image: ${REGISTRY}/my-web:latest-${VERSION}
    restart: unless-stopped
    ports:
      - "3004:3000"
    networks:
      - app-net
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:3000/ || exit 1"]
      interval: 15s
      timeout: 15s
      retries: 10
    depends_on:
      api:
        condition: service_healthy

  api:
    container_name: my-api
    image: ${REGISTRY}/my-api:latest-${VERSION}
    restart: unless-stopped
    ports:
      - "3005:4040"
    networks:
      - app-net
    env_file:
      - ./app.env
    volumes:
      - ./healthcheck.js:/app/healthcheck.js:ro
    healthcheck:
      test: ["CMD-SHELL", "node ./healthcheck.js"]
      interval: 15s
      timeout: 15s
      retries: 10

networks:
  app-net:
    driver: bridge

run.sh

Homepage-style - pull images, docker-compose up, optional cleanup of a generated env file:

#!/bin/sh

docker pull "$2/my-web:latest-$1"
docker pull "$2/my-api:latest-$1"
env VERSION="$1" env REGISTRY="$2" docker-compose up -d $DC_FLAGS api web
rm -rf /opt/devops/ph-vps-1/homepage/app.env

LTS-style - ensure volumes, pull, bring up dependencies and apps:

#!/bin/sh

docker volume create my-db-data || true
docker pull "$2/my-api:latest-$1"
docker pull "$2/my-ui:latest-$1"
env VERSION="$1" env REGISTRY="$2" docker-compose up -d $DC_FLAGS db api ui

DC_FLAGS comes from the workflow (for example --remove-orphans --force-recreate). On hosts with only Compose v2, use docker compose instead of docker-compose.

healthcheck.js

Node fetch against the service's health URL (container-local host/port):

fetch("http://localhost:4040/api/v1/health")
  .then((res) => (res.status === 200 ? process.exit(0) : process.exit(1)))
  .catch((err) => {
    console.error(err);
    process.exit(1);
  });

Operational flow

A common split is:

  1. Validate on a GitHub-hosted runner (for example ubuntu-latest) - parse inputs ("which host", "which service path"), and verify the deployment manifest and required secret keys.
  2. Deploy on a self-hosted runner labeled for the target host, so the job can talk to local Docker and paths under the clone directory.
  3. Secrets - A deploy.json (or similar) maps each service to variable names and optional file targets (for example "write the contents of secret X to /opt/some-service/app.env"). Values may be resolved from a vault integration (for example Bitwarden via API) plus AWS credentials pulled the same way for ECR login.
  4. Final step - cd into the service directory under the fixed checkout (for example cd /opt/devops/$SERVICE) and ./run.sh with image tag and registry - classic Compose on a pet server.

That is not the same as declaring all infrastructure in one Terraform root module. It is operations-as-repo: Compose + shell + Actions, with optional cloud services (container registry, password manager API) where they reduce toil.

deploy-service.yml (basic skeleton)

The workflow validates inputs with Python helpers, loads secrets from Bitwarden, and writes env files from deploy.json. For reuse elsewhere, the shape that matters is: validate on GitHub-hosted, deploy on self-hosted, then ECR login and ./run.sh.

Starter - GitHub secrets for AWS only (no Bitwarden). Point runs-on at your runner labels. Set AWS_ECR_REGISTRY as the registry host your run.sh expects as $2:

name: Deploy Service
permissions:
  contents: read
  actions: write
on:
  workflow_dispatch:
    inputs:
      service:
        description: "Path under the devops clone, e.g. ph-vps-1/homepage"
        required: true
        default: ph-vps-1/homepage
        type: string
      version:
        description: "Image tag suffix (branch / env)"
        required: true
        default: main
        type: string
      flags:
        description: "Extra docker-compose flags"
        required: false
        default: "--remove-orphans --force-recreate"
jobs:
  deploy:
    name: Deploy to host
    runs-on: [self-hosted, linux, ph-vps-1]
    steps:
      - name: Checkout
        uses: actions/checkout@v4
      - name: Configure AWS
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ${{ secrets.AWS_REGION }}
      - name: Login to Amazon ECR
        uses: aws-actions/amazon-ecr-login@v2
        with:
          mask-password: "true"
      - name: Run stack
        env:
          SERVICE: ${{ inputs.service }}
          VERSION: ${{ inputs.version }}
          DC_FLAGS: ${{ inputs.flags }}
          AWS_ECR_REGISTRY: ${{ secrets.AWS_ECR_REGISTRY }}
        run: |
          cd "/opt/devops/$SERVICE"
          ./run.sh "$VERSION" "$AWS_ECR_REGISTRY"
      - name: Cleanup shell history
        if: always()
        shell: bash
        run: |
          history -c || true
          history -w || true

Example service inventory (from typical readmes)

The same pattern often hosts a mix of first-party apps and third-party stacks - for example internal APIs, a static or CMS site (Grav or similar), news or scraper frontends, code quality (SonarQube), database admin (Adminer), or an artifact server for mobile builds. The pattern is uniform; the inventory is whatever your org runs in containers.

Comparison - usual clouds and tools vs this setup

Area Typical AWS / Azure / GCP managed path Terraform (usual use) Vercel / similar PaaS VPS + Compose + self-hosted runner (this pattern)
Unit of deployment Service definitions tied to cloud APIs (task defs, Kubernetes manifests, App Service, etc.). Desired state of resources (IDs, ARNs, RBAC), applied via provider APIs. Project connected to Git - build and route traffic. Docker Compose stacks on fixed hosts, often under a path like /opt/devops/....
Where compute runs Provider-managed pools, often multi-AZ, autoscaling groups, serverless. Does not run your app - provisions where it will run. Provider edge / serverless / managed Node. Your VPS - you choose kernels, disks, networking quirks.
Scaling Often horizontal scaling, load balancers, managed DBs. You model capacity; still need app design. Platform scales execution; limits by product. Mostly vertical or manual - another VPS, split stacks, your problem.
Secrets IAM, Key Vault, Secrets Manager, Parameter Store, etc. Often outputs + references into runtime config. Dashboard / env vars / integrations. Vault-backed scripts + files on disk - custom contract to maintain.
CI/CD touchpoint Pipelines talk to cloud APIs or deploy agents. Apply changes to infra; app deploy may be separate. Git push or webhook - no SSH to your server. Self-hosted runner must be trusted - it has local power on prod.
Images ECR, GCR, ACR are normal. Registry is just another resource. Opaque build - you rarely run raw Docker. Managed registry for images (e.g. ECR) + Compose on the host - hybrid cloud plus self-host.

Terraform is a useful contrast: it excels at creating VPCs, clusters, and IAM in hyperscalers. A central DevOps repo on existing Linux servers is closer to a runbook in Git for machines you already have. Terraform might still provision the VPS; the Compose repo still answers what runs on it - different layers.

Vercel and similar platforms optimize managed builds and routing without VM access. The pattern here optimizes full control on a box - tools that are a poor fit for a single PaaS SKU until you pay for enterprise patterns or run your own anyway.

Pros and cons (trade-offs)

Reasons this model can work well

  • Cost clarity - Predictable VPS bills versus usage-metered cloud bills that can spike when traffic or SKUs grow.
  • Control - Long-lived containers, internal ports, compose networks - fewer artificial limits from a platform.
  • Runtime not locked to one cloud - Compute lives on servers you can name and budget (often still combined with a managed registry and a SaaS vault).
  • Self-hosted runners - Direct access to docker, host paths, and private networks without tunneling everything from GitHub's shared pool.
  • Single operations repo - One place for compose and deploy docs - balanced against the fact that your conventions replace vendor tutorials.

Costs and risks

  • You own reliability - OS updates, disk space, Docker upgrades, reboots - not folded into a managed orchestrator SLA (hardware provider SLAs still apply).
  • Security surface - A compromised self-hosted runner or host sits near production. Shared GitHub-hosted runners are more isolated from your LAN; here trust boundaries are yours to harden.
  • Scaling and HA - No free horizontal scale; backups, DR, and load balancing are extra design work.
  • Operational entropy - JSON manifests, Python helpers, and shell entrypoints are flexible but internal - new teammates will compare against public cloud docs.
  • Split operational model - Images in ECR (or similar) and processes on VPS - permissions and failure modes span both sides.

Who should choose this vs. avoid it

This pattern tends to fit teams that need predictable bills for many long-running services, are fine with manual or incremental scaling, and already accept Linux and CI ownership. It often shows up for small businesses, agency-style hosting, or internal platforms where PaaS limits or pricing hurt more than ops time.

It tends to fit poorly when the priority is elastic scale, minimal operations headcount, strict compliance without ops hires, or fastest adoption of fully managed runtimes. Teams optimizing for velocity and global managed edge often lean on PaaS and cloud APIs, sometimes with Terraform for account guardrails.

Outro

A central operations repository plus Docker Compose on VPS or dedicated hosts, driven by self-hosted GitHub Actions runners and optional cloud glue (registry, secrets API), is a deliberate middle path. It is not a replacement for hyperscaler platforms or for declarative infra as the whole story - it usually sits below that layer, on servers you maintain, with repeatable procedures in version control. The trade is familiar: freedom and often simpler monthly math against responsibility for what breaks when no vendor absorbs the outage for you.

Next Post