A minimal self-hosted CI/CD platform. Push a commit to GitHub → SimpleCI runs your pipeline inside a Docker container → see live logs stream in the browser in real time.
Built with Express + Prisma + PostgreSQL + Dockerode on the backend and React + Vite on the frontend.
- 🔗 GitHub webhook integration — triggers automatically on every
git push - 🔒 HMAC-SHA256 signature verification — every webhook request is cryptographically verified
- 🐳 Dockerized pipeline execution — each run gets a clean, isolated
node:20-alpinecontainer - 📡 Live WebSocket log streaming — watch pipeline output appear in real time in the browser
- 🗄️ Persistent run history — all runs and logs stored in PostgreSQL via Prisma ORM
- 🎨 Dark terminal UI — GitHub-style dark dashboard with status badges and color-coded logs
- 📋
.simpleci.yamlsupport — define custom pipeline steps and branch filters per repo - 🌿 Branch filtering — only trigger CI on branches you configure (e.g.
main,develop) - ↩ Re-run button — retry any failed pipeline from the UI without pushing an empty commit
- 🛡️ Stuck-run recovery — RUNNING runs left by a server crash are auto-marked FAILED on restart
- 🔄 Auto-pull Docker images — pulls
node:20-alpinefrom Docker Hub automatically if not cached - 🔌 DB connection retry — reconnects to PostgreSQL on startup with exponential backoff (no manual intervention needed)
git push
│
▼
GitHub (computes HMAC-SHA256 signature)
│ POST /webhook/github
▼
ngrok tunnel → localhost:3000
│
▼
Express (apps/backend/src/server.ts)
│
├─ verifyGithubWebhook() ← HMAC check, 401 if invalid
├─ prisma.run.create() ← DB: status = RUNNING
├─ res.status(202) ← respond to GitHub immediately
│
└─ runPipeline() [background, no await]
│
├─ docker.createContainer({ Image: 'node:20-alpine' })
│ Cmd: git clone → npm install → npm test → npm run build
│ Memory: 512MB | CPU: 0.5 cores | AutoRemove: true
│
├─ container.logs() stream
│ → prisma.log.create() each line saved to DB
│ → broadcastLog(wss) each line sent to WebSocket
│
└─ container.wait() → exitCode
→ prisma.run.update({ status: SUCCESS | FAILED })
WebSocket (ws://localhost:3000, shared port with Express)
│
▼
React Frontend (localhost:5173)
├─ Dashboard — polls GET /runs every 5s, shows run list
└─ RunDetail — receives live logs via WebSocket, polls status every 3s
| Layer | Technology |
|---|---|
| Backend runtime | Node.js 20 + TypeScript |
| HTTP framework | Express |
| WebSocket | ws library (shared port with Express) |
| ORM | Prisma |
| Database | PostgreSQL 16 (Docker) |
| Container execution | Dockerode (Docker Engine API) |
| Frontend | React 18 + Vite + TypeScript |
| Routing | React Router v6 |
| Fonts | Inter + JetBrains Mono (Google Fonts) |
| Public tunnel | ngrok |
SimpleCI/
├── docker-compose.yml PostgreSQL container
├── .simpleci.yaml Example pipeline config
├── .gitignore
└── apps/
├── backend/
│ ├── prisma/
│ │ ├── schema.prisma DB models (Run, Log)
│ │ └── migrations/ SQL migration files
│ └── src/
│ ├── server.ts Entry point — Express + WebSocket setup
│ ├── db/
│ │ └── prisma.ts Singleton PrismaClient
│ ├── lib/
│ │ └── verifyWebhook.ts HMAC-SHA256 verification
│ ├── routes/
│ │ ├── webhook.ts POST /webhook/github
│ │ └── runs.ts GET /runs, GET /runs/:id, POST /runs/:id/retry
│ └── services/
│ ├── dockerRunner.ts Docker container + log streaming
│ └── pipelineParser.ts .simpleci.yaml parser
└── frontend/
└── src/
├── App.tsx Routes: / and /runs/:id
├── main.tsx React entry point
├── types.ts TypeScript interfaces
├── index.css Design system (dark theme)
├── components/
│ ├── StatusBadge.tsx RUNNING / SUCCESS / FAILED badge
│ └── Terminal.tsx Live log terminal with auto-scroll
└── pages/
├── Dashboard.tsx Run list, auto-refreshes every 5s
└── RunDetail.tsx Live logs + status polling
- Node.js ≥ 18
- Docker Desktop (must be running)
- A GitHub repo with a webhook configured (see below)
- ngrok for the public tunnel
# Backend
cd apps/backend
npm install
# Frontend
cd ../frontend
npm installcd apps/backend
cp .env.example .env # if .env.example exists, otherwise create .envFill in apps/backend/.env:
DATABASE_URL="postgresql://simpleci:simpleci_secret@localhost:5432/simpleci_db"
PORT=3000
GITHUB_WEBHOOK_SECRET="your-secret-here"# From the project root
docker compose up -dcd apps/backend
npx prisma migrate dev --name initcd apps/backend
npm run dev
# → http://localhost:3000cd apps/frontend
npm run dev
# → http://localhost:5173ngrok http 3000
# Copy the https://xxxx.ngrok-free.app URL- Go to your GitHub repo → Settings → Webhooks → Add webhook
- Payload URL:
https://xxxx.ngrok-free.app/webhook/github - Content type:
application/json - Secret: the value of
GITHUB_WEBHOOK_SECRETin your.env - Events: Just the push event
- Save
Now push a commit — the pipeline will trigger automatically.
Put this file in the root of the repo you want SimpleCI to test (not the SimpleCI repo itself).
pipeline:
name: my-app
# Optional: only trigger CI on these branches.
# Remove the 'branches' key entirely to run CI on ALL branches.
branches:
- main
- develop
steps:
- name: Install Dependencies
run: npm install
- name: Run Tests
run: npm test
- name: Build
run: npm run buildHow it works:
- When a push event arrives, SimpleCI does a shallow
git cloneof the repo - It reads
.simpleci.yamlfrom the cloned repo - If a
brancheslist is defined, pushes to other branches are silently ignored - If no
.simpleci.yamlis found, it falls back to the default steps above - Steps are chained with
&&— if any step fails, the pipeline stops and the run is marked FAILED
| Method | Endpoint | Description |
|---|---|---|
GET |
/health |
Liveness check |
POST |
/webhook/github |
GitHub push webhook receiver |
GET |
/runs |
List all pipeline runs (no logs) |
GET |
/runs/:id |
Single run with full log history |
POST |
/runs/:id/retry |
Re-run a pipeline using the same repo + commit |
Connect to ws://localhost:3000. Messages are broadcast to all clients:
{ "runId": "550e8400-...", "line": "npm install complete" }Filter by runId on the client side to show logs for a specific run.
Run
id UUID (primary key)
repoName string
repoUrl string
branch string
commitSha string (7-char short SHA)
commitMsg string
status string RUNNING | SUCCESS | FAILED
startedAt datetime (auto)
finishedAt datetime (null until complete)
logs Log[] (cascade delete)
Log
id int (autoincrement)
runId UUID (foreign key → Run)
line string
createdAt datetime (auto)
- All webhook requests are verified with HMAC-SHA256 before any processing
crypto.timingSafeEqualis used for signature comparison to prevent timing attacks- The raw request body (not parsed JSON) is used for HMAC — ensures byte-exact verification
.envis excluded from version control via.gitignore
- No authentication on the dashboard — anyone with the URL can see all runs
- No concurrency limit — simultaneous pushes create simultaneous Docker containers
- The retry endpoint (
POST /runs/:id/retry) uses default steps rather than re-reading.simpleci.yamlfrom the repo - No build caching —
npm installre-downloads all packages on every run - Only works with public GitHub repos — private repos require Docker credentials
The server may have crashed mid-run. Restart the backend — it automatically marks stuck RUNNING runs as FAILED on startup.
This is handled automatically. SimpleCI pulls the image from Docker Hub on first use. No manual docker pull required. If Docker Desktop is not running, start it first.
PostgreSQL may be slow to start. The backend retries the DB connection 5 times with a 2-second gap. If it still fails:
docker start simpleci-postgres # or: docker compose up -dThen restart the backend.
Make sure the GITHUB_WEBHOOK_SECRET in apps/backend/.env exactly matches the secret you set in GitHub → Repo Settings → Webhooks.
MIT