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.
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.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.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.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
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.
deploy.json declaresbitwarden - 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.
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.
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.
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.
Generalized from ph-vps-1/, and .github/workflows/deploy-service.yml. Swap registry names, images, ports, and paths for your stack.
docker-compose.ymlMulti-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.shHomepage-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.jsNode 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);
});
A common split is:
ubuntu-latest) - parse inputs ("which host", "which service path"), and verify the deployment manifest and required secret keys.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.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
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.
| 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.
docker, host paths, and private networks without tunneling everything from GitHub's shared pool.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.
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.