A production-ready CI/CD scaffold tool and template that works for any language or framework. Supports both GitHub Actions and GitLab CI with 100% free-tier tools and zero secrets in the repository.
What is Axon? Axon is a framework for setting up CI/CD and monitoring on any project, in any language. You run
axon initinside your project and it scaffolds Docker-based pipelines (GitHub Actions and GitLab CI), a deploy flow, and an optional Prometheus / Grafana / Loki monitoring stack. Axon is infrastructure tooling you drop into other projects - it is not an application or a library you import, and it has no business logic of its own. Think of it ascreate-react-app, but for your delivery pipeline and observability instead of a frontend.
| Axon is | Axon is not |
|---|---|
| A scaffold + template for CI/CD and monitoring | An application, API, or service |
| Language-agnostic - you edit only the Dockerfile | A library you import into your code |
| GitHub Actions + GitLab CI, free-tier only | Tied to a single language or framework |
| A Docker-based deploy flow + Prometheus/Grafana/Loki stack | A place where your business logic lives |
Note on the name: this Axon is a CI/CD and monitoring scaffold. It is unrelated to other projects that
share the name "Axon" (for example the Elixir Nx neural-network library) - there is no machine-learning
code here.
cd your-project # any language: Node, Python, Go, PHP, Rust, ...
axon init # drops in CI/CD pipelines + Docker + monitoring config
# edit the Dockerfile for your stack, set CI secrets, push
# -> test -> build image -> deploy -> health check -> metrics + logs + alerts
sudo dnf install https://github.com/suphakin-th/axon/releases/latest/download/axon-1.0.0-1.noarch.rpmsudo apt install https://github.com/suphakin-th/axon/releases/latest/download/axon_1.0.0_all.debbrew tap suphakin-th/axon
brew install axon# With sudo (installs to /usr/local/bin)
curl -fsSL https://raw.githubusercontent.com/suphakin-th/axon/main/install.sh | sudo sh
# Without sudo (installs to ~/.local/bin)
curl -fsSL https://raw.githubusercontent.com/suphakin-th/axon/main/install.sh | shgit clone https://github.com/suphakin-th/axon.git
cd axon
sudo make install# Inside WSL or Git Bash
curl -fsSL https://raw.githubusercontent.com/suphakin-th/axon/main/install.sh | shaxon <command> [options]
Commands:
init Scaffold CI/CD files into the current project
encode Encode a .env file to base64 for CI/CD secrets
decode Decode a base64 string back to readable .env
version Show version number
help Show help
Options for init:
--platform github, gitlab, or both (default: both)
--force Overwrite existing files
Scaffold CI/CD into any project:
cd your-project
axon init # GitHub Actions + GitLab CI
axon init --platform github # GitHub Actions only
axon init --platform gitlab # GitLab CI only
axon init --force # overwrite existing filesEncode your .env for CI/CD secrets:
axon encode .env.uat # encode UAT env -> paste as APP_ENV_B64
axon encode .env.prod # encode PROD env -> paste as APP_ENV_B64
axon decode "$APP_ENV_B64" # preview what a secret decodes toThis repository provides a complete CI/CD foundation for web apps, APIs, microservices, and mobile projects. Docker is the abstraction layer, so the pipeline itself never changes regardless of what language you use. You adapt only the Dockerfile.
Key properties:
- Works on GitHub Actions and GitLab CI with equivalent pipelines
- No external paid services required
- Builds multi-arch images (linux/amd64 + linux/arm64) - the same image runs on cloud VMs and on ARM boards like a Raspberry Pi
- GitHub deploys reach the server over a Cloudflare Tunnel - no inbound ports, no SSH exposed to the internet
- All secrets stored per-environment in CI/CD variables, never in code
- .env is written to the server at deploy time from a base64-encoded secret
- Monitoring stack included (Prometheus, Grafana, Loki, Alertmanager)
- A server with Docker and Docker Compose installed - any of: a cloud VM, a home server, or an ARM board such as a Raspberry Pi 5 (64-bit OS required for arm64 images)
- A
deployuser on the server that is a member of the docker group, with an ed25519 key pair (no passphrase) - A way for GitHub to reach the server - a Cloudflare Tunnel is recommended (no open ports); a public IP with direct SSH also works
- A GitHub or GitLab account, and a free Cloudflare account if using the tunnel
Server setup (one-time):
# Debian/Ubuntu/Raspberry Pi OS
curl -fsSL https://get.docker.com | sh
sudo adduser --disabled-password --gecos "" deploy
sudo usermod -aG docker deploy
sudo mkdir -p /home/deploy/.ssh && sudo chmod 700 /home/deploy/.sshGenerate the deploy key pair and trust it for the deploy user:
ssh-keygen -t ed25519 -C "ci-deploy" -f ~/deploy_key -N ""
sudo tee /home/deploy/.ssh/authorized_keys < ~/deploy_key.pub > /dev/null
sudo chown -R deploy:deploy /home/deploy/.ssh
sudo chmod 600 /home/deploy/.ssh/authorized_keys
# ~/deploy_key (private) -> CI secret DEPLOY_SSH_KEYThe GitHub deploy connects through a Cloudflare Tunnel, so the server needs no open ports and SSH is never exposed to the internet.
- Install and run
cloudflaredon the server and create a tunnel that maps a hostname (e.g.ssh.example.com) tossh://localhost:22. - In Cloudflare Zero Trust, create an Access service token (
Access -> Service Auth) and a self-hosted Access application protecting that hostname with a Service Auth policy that allows the token. - The runner authenticates with the token and tunnels SSH via
ProxyCommand cloudflared access ssh --hostname %h(already wired inci.yml).
The token's Client ID and Secret become the CF_ACCESS_CLIENT_ID and CF_ACCESS_CLIENT_SECRET secrets below, and DEPLOY_HOST is the tunnel hostname.
# Clone and enter the repo
git clone https://github.com/suphakin-th/axon.git
cd axon
# Copy environment template
cp .env.example .env
# Edit .env with your local values
# Start local dev (hot-reload)
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# Run tests
docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm testfeature/* --+
fix/* --+--> develop --MR--> uat --MR--> main
refactor/* --+ | |
auto-deploy manual gate
to UAT to PROD
All branches except develop and feature/* are protected and require merge requests. Direct push to uat and main is blocked.
[test] -> [build] -> [deploy:uat (auto)] uat branch
-> [deploy:prod (manual)] main branch
|
[migrate (manual)] both envs
Runs on every pull request and every push to uat and main.
docker compose -f docker-compose.yml -f docker-compose.test.yml run --rm testSpins up an ephemeral database container for the test run, then discards it. The test command is defined in the Dockerfile test stage CMD and can be anything your language uses.
Runs only on pushes to uat and main (not on pull requests).
Builds a multi-arch Docker image (linux/amd64 + linux/arm64 via QEMU), tags it with both the git SHA (immutable) and branch name (cache pointer), then pushes to the container registry. The arm64 variant is what lets the same image run on a Raspberry Pi or other ARM server.
- GitHub: ghcr.io (free for public repos, 500 MB for private)
- GitLab: GitLab Container Registry (free, unlimited)
| Job | When | Gate |
|---|---|---|
| deploy:uat | uat push | Automatic after build succeeds |
| deploy:prod | main push | Manual - reviewer approves in GitHub / click Play in GitLab |
The deploy job:
- Connects to the server over the Cloudflare Tunnel (SSH via
cloudflaredProxyCommand, authenticated by the Access service token) - Copies docker-compose.yml to the server's DEPLOY_PATH
- Decodes APP_ENV_B64 from the CI secret and writes it to DEPLOY_PATH/.env (carriage returns stripped first)
- Appends APP_VERSION (git SHA) and APP_IMAGE to the .env
- Runs docker compose pull and docker compose up -d --remove-orphans
- Polls the health endpoint for up to 2 minutes
Run multiple environments on one host by giving each its own APP_PORT, DEPLOY_PATH, and COMPOSE_PROJECT_NAME (set in the .env) so containers, networks, and ports never collide.
Always manual. Never runs automatically.
| Platform | How to trigger |
|---|---|
| GitHub | Actions tab -> migrate.yml -> Run workflow -> choose env |
| GitLab | Click Play on migrate:uat or migrate:prod in pipeline UI |
Always check pending migrations before clicking. Review what will change before proceeding.
| Tool | Free allowance | Purpose |
|---|---|---|
| GitHub Actions | 2,000 min/month private, unlimited public | CI runner |
| GitLab CI | 400 min/month SaaS, unlimited self-hosted | CI runner |
| ghcr.io | Free public, 500 MB private | Image registry for GitHub |
| GitLab Container Registry | Free unlimited | Image registry for GitLab |
| Cloudflare Tunnel + Access | Free | Reach the server with no open ports; service-token auth for CI |
| Docker Compose | Free | Dev and production orchestration |
Secrets are never stored in the repository. The .env is encoded as base64 and stored in CI, then decoded on the server at deploy time.
Encode your .env file:
# macOS / Linux
base64 -w 0 .env.uat # paste as APP_ENV_B64 in the uat environment
base64 -w 0 .env.prod # paste as APP_ENV_B64 in the production environment
# Windows PowerShell
[Convert]::ToBase64String([IO.File]::ReadAllBytes('.env.uat')) | clip
[Convert]::ToBase64String([IO.File]::ReadAllBytes('.env.prod')) | clip| Secret | Description |
|---|---|
| DEPLOY_HOST | Tunnel hostname (e.g. ssh.example.com), or server IP if not using a tunnel |
| DEPLOY_USER | SSH username (e.g. deploy) |
| DEPLOY_SSH_KEY | Private SSH key, ed25519, no passphrase |
| CF_ACCESS_CLIENT_ID | Cloudflare Access service token Client ID |
| CF_ACCESS_CLIENT_SECRET | Cloudflare Access service token Client Secret |
| APP_ENV_B64 | base64-encoded .env content for this env |
| APP_PORT | Port the app exposes (e.g. 8080) |
| HEALTH_PATH | Health check endpoint (e.g. /health) |
| DEPLOY_PATH | Server directory (e.g. /home/deploy/axon) |
| MIGRATE_CMD | Migration command (e.g. npm run migrate) |
GITHUB_TOKEN is auto-generated per job and used for ghcr.io auth. No setup required. CF_ACCESS_* are only needed when deploying through a Cloudflare Tunnel.
To enable the manual gate for production: Settings -> Environments -> production -> Required reviewers -> add yourself.
Note: do not pipe a secret's value into gh secret set from PowerShell - it injects carriage
returns (CRLF) that corrupt SSH keys and base64 values. Use the GitHub UI, or pipe from a Unix
shell (Git Bash, WSL). The axon encode command writes clean output for this reason.
| Variable | Scope | Masked | Description |
|---|---|---|---|
| DEPLOY_SSH_KEY | All | No | Private SSH key (File type) |
| DEPLOY_HOST | uat | No | UAT server IP |
| DEPLOY_HOST | production | No | PROD server IP |
| DEPLOY_USER | uat/prod | No | SSH username per env |
| APP_ENV_B64 | uat | Yes | base64 .env for UAT |
| APP_ENV_B64 | production | Yes | base64 .env for PROD |
| APP_PORT | uat/prod | No | App port per env |
| HEALTH_PATH | uat/prod | No | Health check path per env |
| DEPLOY_PATH | uat/prod | No | Server path per env |
| MIGRATE_CMD | uat/prod | No | Migration command per env |
Edit only the Dockerfile. The pipeline does not change.
Each stage has inline comments showing how to swap the base image and commands.
| Language | deps base image | Test command | Run command |
|---|---|---|---|
| Node.js | node:20-alpine | npm test | node dist/server.js |
| Python | python:3.12-slim | pytest -v | gunicorn app:app |
| Go | golang:1.22-alpine | go test ./... | /app/server (compiled) |
| PHP (Laravel) | php:8.4-cli-alpine | php artisan test | nginx + php-fpm |
| Java (Maven) | eclipse-temurin:21 | mvn test | java -jar app.jar |
| Ruby | ruby:3.3-alpine | bundle exec rspec | bundle exec puma |
| .NET | mcr.microsoft.com/dotnet/sdk:8.0 | dotnet test | dotnet app.dll |
For mobile projects (React Native, Flutter):
- test stage: flutter test or npm test (Jest)
- builder stage: flutter build apk or react-native bundle
- deploy stage: upload to Firebase App Distribution or publish to store
An optional observability stack that runs on a dedicated monitoring server. Collects metrics, logs, and alerts from all app servers.
App Servers (UAT + PROD) Monitoring Server
Node Exporter :9100 -----------> Prometheus :9090
cAdvisor :8080 -----------> scrape_interval: 15s
Redis Exporter:9121 -----------> retention: 30 days
Promtail :9080 -- push --> Loki :3100
Blackbox (on monitoring) --------> Prometheus (HTTP probes)
probe http://UAT-HOST
probe http://PROD-HOST
Grafana :3000
datasource: Prometheus
datasource: Loki
Alertmanager :9093
email -> on-call team
| Service | Image | Port | Purpose |
|---|---|---|---|
| Prometheus | prom/prometheus:v2.53.0 | 9090 | Metrics collection/storage |
| Grafana | grafana/grafana:12.0.0 | 3000 | Dashboards and visualization |
| Loki | grafana/loki:3.5.0 | 3100 | Log aggregation |
| Alertmanager | prom/alertmanager:v0.27.0 | 9093 | Alert routing and silencing |
| Blackbox | prom/blackbox-exporter:v0.25.0 | 9115 | HTTP/TCP endpoint probing |
Installed via monitoring/install-agents.sh on each app server.
| Agent | Version | Port | Collects |
|---|---|---|---|
| Node Exporter | v1.8.1 | 9100 | CPU, memory, disk, network |
| cAdvisor | v0.49.1 | 8080 | Docker container resource usage |
| Promtail | v3.5.0 | 9080 | Container logs, pushed to Loki |
| Redis Exporter | v1.62.0 | 9121 | Redis memory, commands, latency |
Install agents:
# UAT server
scp monitoring/install-agents.sh deploy@UAT_HOST:~/
ssh deploy@UAT_HOST "SERVER_ENV=uat bash ~/install-agents.sh"
# PROD server
scp monitoring/install-agents.sh deploy@PROD_HOST:~/
ssh deploy@PROD_HOST "SERVER_ENV=prod bash ~/install-agents.sh"| Alert | Condition | Severity | Fires after |
|---|---|---|---|
| InstanceDown | Node Exporter unreachable | critical | 2 min |
| HTTPDown | HTTP probe fails | critical | 2 min |
| HTTPSlowResponse | HTTP response > 5s | warning | 5 min |
| HighCPU | CPU usage > 85% | warning | 5 min |
| HighMemory | Memory usage > 90% | warning | 5 min |
| HighDisk | Disk usage > 85% on / | warning | 5 min |
| ContainerDown | App container missing from cAdvisor | critical | 2 min |
| HighRedisMemory | Redis memory > 85% of maxmemory | warning | 5 min |
| RedisDown | Redis Exporter unreachable | critical | 2 min |
Critical alerts repeat every 1 hour. Warning alerts repeat every 4 hours. Critical alert inhibits warning alert from the same instance.
The CI pipeline deploys monitoring changes automatically when files under monitoring/ change on uat or main.
To deploy manually:
# Full redeploy
scp -r monitoring/ deploy@MONITORING_HOST:~/monitoring/
ssh deploy@MONITORING_HOST "cd ~/monitoring && docker compose up -d --remove-orphans"
# Reload Prometheus config only (no restart)
scp monitoring/prometheus/prometheus.yml deploy@MONITORING_HOST:~/monitoring/prometheus/prometheus.yml
ssh deploy@MONITORING_HOST "curl -s -X POST http://localhost:9090/-/reload"
# Restart Alertmanager after rule changes
scp monitoring/prometheus/rules/alerts.yml deploy@MONITORING_HOST:~/monitoring/prometheus/rules/alerts.yml
ssh deploy@MONITORING_HOST "cd ~/monitoring && docker compose restart prometheus"Docker 29.x uses the containerd snapshotter by default. cAdvisor cannot resolve per-container CPU/memory layers when this is enabled. The dashboard shows Docker daemon aggregate metrics instead.
Permanent fix (requires a brief Docker restart):
sudo tee -a /etc/docker/daemon.json <<'EOF'
{"features": {"containerd-snapshotter": false}}
EOF
sudo systemctl restart docker| File | Purpose |
|---|---|
| .github/workflows/ci.yml | GitHub Actions: test -> build -> deploy |
| .github/workflows/migrate.yml | GitHub Actions: manual migration workflow |
| .gitlab-ci.yml | GitLab CI: equivalent pipeline |
| Dockerfile | Multi-stage universal build template |
| docker-compose.yml | Production stack (image-only) |
| docker-compose.dev.yml | Local dev with hot-reload and local DB |
| docker-compose.test.yml | CI test environment with ephemeral DB |
| .env.example | Environment variable template |
| .gitignore | Blocks .env* and CI tool artifacts |
Find the previous image SHA from the pipeline history, then on the server:
ssh deploy@DEPLOY_HOST # or your tunnel host alias
cd "$DEPLOY_PATH" # e.g. /home/deploy/axon
# Edit .env: set APP_VERSION to the previous commit SHA
docker compose pull
docker compose up -d --remove-orphansOr re-run the previous pipeline's deploy job from the CI UI.
Migrations do not auto-rollback. Options:
Option A - Restore from backup (if taken before migrate):
pg_restore -U postgres -h DB_HOST -d DATABASE -c /tmp/backup_TIMESTAMP.dumpOption B - Write a compensating migration:
# Create a new migration that reverses the change, then deploy and run migrateOption C - Roll back one step if down() is implemented:
ssh deploy@DEPLOY_HOST
cd "$DEPLOY_PATH"
docker compose run --rm --no-deps app sh -c "your-framework rollback --step=1"