Minimalist self-hosted CI/CD server built with Deno and React. Define pipelines in JSON, execute shell commands, HTTP requests, Git operations, or run steps in isolated Docker containers.
- JSON Pipelines — Define automation workflows in simple JSON format
- Modular Steps — Built-in modules: shell, docker, docker_remote, http, git, fs, delay, wait, notify, archive, ssh, s3, json, pipeline, queue, crypto
- Docker Runner — Execute steps in isolated Docker containers with resource limits
- Parallel Execution — Run multiple steps simultaneously
- Variable Interpolation — Access step results via
${results.stepName}and${prev} - Pipeline Inputs — Parameterize pipelines with runtime inputs (string, boolean, select)
- Dynamic Environment — Select environment at runtime via
${inputs.ENV} - Smart Editor — Monaco editor with autocomplete for modules, parameters, and variables
- Sandboxed Execution — Each pipeline run gets an isolated working directory
- Cron Scheduling — Schedule pipelines with cron expressions
- Real-time Logs — WebSocket-based live log streaming
- Web UI — Modern React + Material UI interface
- Docker Ready — Deploy with Docker Compose
# Clone and start
git clone <repo-url> homeworkci
cd homeworkci
docker compose up -d --build
# Open http://localhost:80Prerequisites: Deno v2.0+, Node.js v20+
# Start backend
deno task start
# In another terminal - start frontend dev server
cd client && npm install && npm run dev
# Open http://localhost:5173homeworkci/
├── server/ # Deno backend (Hono, pipeline engine)
├── client/ # React frontend (Vite, Material UI)
├── modules/ # Pipeline step modules (TypeScript)
├── pipelines/ # Pipeline definitions (JSON)
├── config/ # Runtime configuration
├── docker/ # Dockerfiles and nginx config
└── data/ # SQLite database
Pipelines are JSON files in the pipelines/ directory:
{
"name": "My Pipeline",
"description": "Optional description",
"schedule": "0 */6 * * *",
"env": "production",
"keepWorkDir": false,
"steps": [
{
"name": "step_name",
"description": "What this step does",
"module": "shell",
"params": {
"cmd": "echo 'Hello World'"
}
}
]
}| Field | Type | Description |
|---|---|---|
name |
string | Pipeline display name |
description |
string | Optional description |
schedule |
string | Cron expression for scheduled runs |
env |
string | Environment name from config/variables.json |
keepWorkDir |
boolean | Keep sandbox directory after completion (debugging) |
steps |
array | Array of step objects |
| Field | Type | Description |
|---|---|---|
name |
string | Step name (used for ${results.name}) |
description |
string | Step description |
module |
string | Module to execute: shell, docker, docker_remote, http, git, fs, delay, wait, crypto |
params |
object | Module-specific parameters |
dependsOn |
string | string[] | Step names this step depends on (must succeed first) |
Define runtime parameters that users can configure when starting a pipeline:
{
"name": "Parameterized Pipeline",
"env": "${inputs.ENV}",
"inputs": [
{
"name": "ENV",
"type": "select",
"label": "Environment",
"options": ["dev", "staging", "prod"],
"default": "dev"
},
{
"name": "verbose",
"type": "boolean",
"label": "Verbose output",
"default": false
}
],
"steps": [...]
}| Input Type | Description |
|---|---|
string |
Text input field |
boolean |
Checkbox |
select |
Dropdown with predefined options |
The env field supports interpolation, allowing environment selection at runtime:
{
"env": "${inputs.ENV}",
"inputs": [
{ "name": "ENV", "type": "select", "options": ["dev", "prod"] }
]
}Access data from previous steps and inputs in parameters:
${prev}— Result of the previous step${results.stepName}— Result of a named step${results.stepName.field}— Nested field access${env.VAR_NAME}— Environment variable${inputs.inputName}— Runtime input value${pipelineId}— Current pipeline ID${BUILD_ID}— Unique build ID for this pipeline run (timestamp-based)${UNIXTIMESTAMP}— Unix timestamp of pipeline start time${WORK_DIR}— Working directory (sandbox path)${DATE}— Date in YYYY-MM-DD format${TIME}— Time in HH:MM:SS format${DATETIME}— Date and time in ISO format (YYYY-MM-DDTHH:MM:SS)${YEAR}— Year (YYYY)${MONTH}— Month (MM, 01-12)${DAY}— Day (DD, 01-31)${PIPELINE_NAME}— Pipeline display name
Store configuration values in config/variables.json for use across pipelines.
{
"global": {
"NOTIFY_CHAT_ID": "-1001234567890",
"API_BASE_URL": "https://api.example.com"
},
"environments": {
"dev": {
"DEPLOY_HOST": "dev.example.com",
"DEPLOY_TOKEN": "dev-token-xxx"
},
"prod": {
"DEPLOY_HOST": "example.com",
"DEPLOY_TOKEN": "prod-token-yyy"
}
}
}Available in all pipelines regardless of environment setting:
{
"name": "Notify Pipeline",
"steps": [
{
"module": "http",
"params": {
"url": "${env.API_BASE_URL}/webhook"
}
},
{
"module": "notify",
"params": {
"type": "telegram",
"chatId": "${env.NOTIFY_CHAT_ID}",
"message": "Done!"
}
}
]
}Available when pipeline specifies env field. Merged with global variables (environment values override global):
{
"name": "Deploy",
"env": "prod",
"steps": [
{
"module": "shell",
"params": {
"cmd": "deploy --host ${env.DEPLOY_HOST} --token ${env.DEPLOY_TOKEN}"
}
}
]
}Variables are merged in order (later values override earlier):
- System environment — Filtered safe variables (PATH, HOME, USER, etc.)
- Global variables — From
config/variables.json - Environment variables — From selected environment
Manage variables via the Variables page in the web UI.
Execute shell commands in the sandbox directory.
{
"module": "shell",
"params": {
"cmd": "npm install && npm test"
}
}Run commands in isolated Docker containers. Requires DOCKER_ENABLED=true.
{
"module": "docker",
"params": {
"image": "node:20-alpine",
"cmd": "npm test",
"workdir": "/workspace",
"network": "bridge",
"memory": "512m",
"cpus": "1",
"reuse": false,
"removeImage": false
}
}| Parameter | Default | Description |
|---|---|---|
image |
alpine:3.19 |
Docker image |
cmd |
required | Command to execute |
workdir |
/workspace |
Working directory in container |
network |
bridge |
Network mode: none, bridge, host |
memory |
512m |
Memory limit |
cpus |
1 |
CPU limit |
reuse |
false |
Reuse container for all steps with reuse: true |
removeImage |
false |
Remove image after execution |
Reuse Mode: When reuse: true, a persistent container is started on the first step and reused for subsequent steps. Installed packages and files persist between steps.
Pull a Docker image on a remote host over SSH and run a container (pull + run only).
{
"module": "docker_remote",
"params": {
"host": "1.2.3.4",
"user": "deploy",
"keyName": "prod-ssh",
"image": "nginx:1.27",
"sudo": true,
"name": "nginx",
"ports": ["80:80"],
"restart": "always"
}
}| Parameter | Description |
|---|---|
host |
Remote host address |
user |
SSH username |
port |
SSH port (default: 22) |
keyName |
SSH key name from Variables page (recommended) |
privateKey |
SSH private key content (alternative to keyName) |
image |
Docker image to pull and run (e.g., nginx:1.27) |
sudo |
Use sudo for docker commands (default: false) |
timeout |
Operation timeout in milliseconds (default: 60000) |
name |
Container name (force-removed before run if exists) |
detach |
Run container in detached mode (default: true) |
restart |
Restart policy: no, always, on-failure, unless-stopped |
ports |
Port mappings array: ["8080:80", "443:443"] |
env |
Environment variables map → -e KEY=VALUE |
volumes |
Volume mounts array: ["/host:/container:ro"] |
extraArgs |
Raw docker run args before image (e.g., --add-host foo:1.2.3.4) |
cmd |
Command passed after image |
Behavior: checks that Docker is present on the host, inspects previous image ID, pulls image, inspects new ID, removes old container by name if provided, and runs the new container. Returns previous/new image IDs and stdout/stderr.
Make HTTP requests.
{
"module": "http",
"params": {
"url": "https://api.example.com/webhook",
"method": "POST",
"body": { "status": "success" }
}
}Git operations: clone, pull, commit, push, tag, checkout, branch, status, log, info.
{
"module": "git",
"params": {
"op": "clone",
"repo": "https://github.com/user/repo.git",
"dir": "./repo"
}
}{
"module": "git",
"params": {
"op": "commit",
"message": "Update files",
"add": true
}
}{
"module": "git",
"params": {
"op": "status"
}
}| Parameter | Description |
|---|---|
op |
Operation: clone, pull, commit, push, tag, checkout, branch, status, log, info |
repo |
Repository URL (required for clone) |
dir |
Target directory |
message |
Commit message (required for commit) |
add |
Add all changes before commit (for commit) |
remote |
Remote name (default: origin, for push) |
branch |
Branch name (for push, checkout, branch) |
tagName |
Tag name (for tag) |
tagOp |
Tag operation: create or delete (for tag) |
branchOp |
Branch operation: create or delete (for branch) |
limit |
Number of commits to return (for log, default: 10) |
Returns:
clone,pull,commit,push,tag,checkout,branch:{ "success": true }or{ "skipped": true }status: Object with branch, clean status, files array, and statisticslog: Object with commits array and countinfo: Object with branch, commit, author, email, remoteUrl
Cryptographic operations: hash generation, encoding/decoding, random tokens, encryption/decryption.
{
"module": "crypto",
"params": {
"op": "hash",
"input": "hello world",
"algorithm": "SHA-256",
"encoding": "hex"
}
}{
"module": "crypto",
"params": {
"op": "encode",
"input": "hello",
"encoding": "base64"
}
}{
"module": "crypto",
"params": {
"op": "random",
"length": 32,
"encoding": "hex"
}
}{
"module": "crypto",
"params": {
"op": "encrypt",
"input": "secret data",
"key": "base64encodedkey...",
"algorithm": "AES-GCM"
}
}| Parameter | Description |
|---|---|
op |
Operation: hash, encode, decode, random, encrypt, decrypt |
input |
Input data (required for hash, encode, decode, encrypt, decrypt) |
algorithm |
Hash/encrypt algorithm: MD5, SHA-256, SHA-512, AES-GCM, AES-CBC (required for hash, encrypt, decrypt) |
encoding |
Encoding format: hex, base64, base64url (for hash, encode, decode, random, default: hex) |
key |
Encryption key in base64 or hex format (required for encrypt, decrypt) |
iv |
Initialization vector in base64 format (required for decrypt) |
length |
Length of random token in bytes (for random, default: 32) |
Returns:
hash:{ "hash": string, "algorithm": string, "encoding": string }encode: Encoded stringdecode: Decoded stringrandom: Random token stringencrypt:{ "encrypted": string, "algorithm": string, "iv": string }decrypt: Decrypted string
File system operations.
{
"module": "fs",
"params": {
"op": "read",
"path": "./config.json"
}
}{
"module": "fs",
"params": {
"op": "write",
"path": "./output.txt",
"content": "Hello World"
}
}Wait for a specified time.
{
"module": "delay",
"params": {
"ms": 5000
}
}Wait for conditions to be met: HTTP endpoint availability, file existence, or process completion. Uses polling with configurable intervals and timeouts.
{
"module": "wait",
"params": {
"op": "http",
"url": "https://api.example.com/health",
"timeout": 30000,
"interval": 1000
}
}{
"module": "wait",
"params": {
"op": "file",
"path": "./output.txt",
"timeout": 60000
}
}{
"module": "wait",
"params": {
"op": "process",
"pid": 12345,
"timeout": 30000
}
}| Parameter | Description |
|---|---|
op |
Operation type: http (wait for HTTP endpoint), file (wait for file to exist), process (wait for process to finish) |
url |
HTTP endpoint URL (required for http operation) |
method |
HTTP method (for http, default: GET) |
expectedStatus |
Expected HTTP status code (for http, default: 200) |
headers |
Custom HTTP headers (for http) |
path |
File path to wait for (required for file operation) |
pid |
Process ID to wait for (required for process operation) |
timeout |
Maximum wait time in milliseconds (default: 60000) |
interval |
Polling interval in milliseconds (default: 1000, minimum: 100) |
retries |
Maximum number of attempts (alternative to timeout, if set, timeout is ignored) |
Returns: { "success": true, "waited": <ms>, "attempts": <number>, "operation": <string> }
Send notifications to messaging platforms (Telegram, Slack).
{
"module": "notify",
"params": {
"type": "telegram",
"token": "${env.TG_BOT_TOKEN}",
"chatId": "${env.TG_CHAT_ID}",
"message": "Build completed!",
"parseMode": "HTML"
}
}{
"module": "notify",
"params": {
"type": "slack",
"webhook": "${env.SLACK_WEBHOOK_URL}",
"message": "Build completed!",
"channel": "#deploys"
}
}| Parameter | Description |
|---|---|
type |
Platform: telegram or slack |
token |
Telegram bot token |
chatId |
Telegram chat ID |
parseMode |
Telegram: HTML or Markdown |
webhook |
Slack Incoming Webhook URL |
channel |
Slack channel override |
username |
Slack username override |
iconEmoji |
Slack icon emoji (e.g., :rocket:) |
attachments |
Slack attachments array |
Create or extract ZIP archives.
{
"module": "archive",
"params": {
"op": "zip",
"source": "./dist",
"output": "./artifacts/build.zip"
}
}Execute remote commands or copy files via SSH/SCP.
Recommended: Use SSH keys from Variables page
Generate SSH keys on the Variables page, then reference them by name:
{
"module": "ssh",
"params": {
"op": "exec",
"host": "server.example.com",
"user": "deploy",
"keyName": "production-server",
"cmd": "systemctl restart app"
}
}Alternative: Direct private key
You can also provide the private key directly (less secure):
{
"module": "ssh",
"params": {
"op": "exec",
"host": "server.example.com",
"user": "deploy",
"privateKey": "${env.SSH_PRIVATE_KEY}",
"cmd": "systemctl restart app"
}
}SCP example:
{
"module": "ssh",
"params": {
"op": "scp",
"host": "server.example.com",
"user": "deploy",
"keyName": "production-server",
"source": "./dist/",
"destination": "/var/www/app/",
"recursive": true
}
}| Parameter | Description |
|---|---|
op |
Operation: exec (command) or scp (copy files) |
host |
Remote host address |
port |
SSH port (default: 22) |
user |
SSH username |
keyName |
SSH key name from Variables page (recommended) |
privateKey |
SSH private key content (alternative to keyName) |
cmd |
Command to execute (required for exec) |
source |
Local path (required for scp) |
destination |
Remote path (required for scp) |
recursive |
Recursive copy for directories (default: true) |
timeout |
Operation timeout in milliseconds (default: 60000) |
SSH Key Management:
- Go to Variables page
- In SSH Keys section, click Generate SSH Key
- Enter a name (e.g.,
production-server) - Copy the public key and add it to the remote server's
~/.ssh/authorized_keys - Use
keyNameparameter in your pipeline steps
Returns:
exec:{ "code": 0, "stdout": "...", "stderr": "..." }scp:{ "success": true, "files": 5 }
S3-compatible storage operations (AWS S3, MinIO, DigitalOcean Spaces).
{
"module": "s3",
"params": {
"op": "upload",
"bucket": "my-artifacts",
"source": "./dist/build.zip",
"key": "releases/v1.0.0/build.zip",
"endpoint": "${env.S3_ENDPOINT}",
"accessKey": "${env.S3_ACCESS_KEY}",
"secretKey": "${env.S3_SECRET_KEY}"
}
}| Parameter | Description |
|---|---|
op |
Operation: upload, download, list, delete |
bucket |
S3 bucket name |
key |
Object key (path in bucket) |
source |
Local file for upload |
output |
Destination for download |
prefix |
Prefix filter for list |
endpoint |
S3-compatible endpoint URL |
region |
AWS region (default: us-east-1) |
accessKey |
Access key ID |
secretKey |
Secret access key |
JSON manipulation operations.
{
"module": "json",
"params": {
"op": "get",
"input": "${results.apiResponse}",
"path": "$.data.items[0].id"
}
}| Parameter | Description |
|---|---|
op |
Operation: parse, get, set, stringify, merge |
input |
Input data (string for parse, object for others) |
path |
JSONPath for get/set (e.g., $.data.items[0]) |
value |
Value for set operation |
merge |
Object to merge |
pretty |
Pretty print (for stringify) |
Run another pipeline as a step. This allows composing pipelines and reusing common workflows.
{
"module": "pipeline",
"params": {
"pipelineId": "build-and-test",
"inputs": {
"version": "${results.build.version}",
"environment": "${inputs.env}"
},
"failOnError": true
}
}| Parameter | Description |
|---|---|
pipelineId |
ID of the pipeline to run (required) |
inputs |
Input parameters to pass to child pipeline (optional, supports interpolation) |
failOnError |
Stop parent pipeline if child fails (optional, default: true) |
Returns:
- On success:
{ "success": true, "runId": "...", "duration": 1234 } - On failure with
failOnError: false:{ "success": false, "runId": "", "duration": 0, "error": "..." }
Notes:
- Child pipeline runs in its own isolated sandbox
- Results from child pipeline can be accessed via
${prev}or${results.stepName}in subsequent steps - If
failOnErrorisfalse, the parent pipeline continues even if the child fails - Child pipeline must exist and not be already running
Message queue operations for RabbitMQ, Redis, AWS SQS, and Google Cloud Pub/Sub.
RabbitMQ Example:
{
"module": "queue",
"params": {
"op": "publish",
"provider": "rabbitmq",
"host": "http://localhost:15672",
"username": "${env.RABBITMQ_USER}",
"password": "${env.RABBITMQ_PASS}",
"exchange": "notifications",
"routingKey": "build.completed",
"message": "Build ${BUILD_ID} completed successfully"
}
}AWS SQS Example:
{
"module": "queue",
"params": {
"op": "consume",
"provider": "sqs",
"queueUrl": "https://sqs.us-east-1.amazonaws.com/123456789/my-queue",
"region": "us-east-1",
"accessKey": "${env.AWS_ACCESS_KEY}",
"secretKey": "${env.AWS_SECRET_KEY}",
"timeout": 10
}
}| Parameter | Description |
|---|---|
op |
Operation: publish (send) or consume (receive) |
provider |
Provider: rabbitmq, redis, sqs, pubsub |
| RabbitMQ | |
host |
Management API endpoint (e.g., http://localhost:15672) |
username |
RabbitMQ username |
password |
RabbitMQ password |
vhost |
Virtual host (default: /) |
exchange |
Exchange name (for publish) |
routingKey |
Routing key (for publish) |
queue |
Queue name (for consume) |
| Redis | |
host |
Redis HTTP API endpoint |
apiKey |
API key (if required) |
channel |
Pub/Sub channel name |
list |
List name (alternative to channel) |
| AWS SQS | |
queueUrl |
SQS queue URL |
region |
AWS region (default: us-east-1) |
accessKey |
AWS access key ID |
secretKey |
AWS secret access key |
| Google Cloud Pub/Sub | |
project |
GCP project ID |
topic |
Topic name (for publish) |
subscription |
Subscription name (for consume) |
serviceAccount |
Service account JSON object |
| Common | |
message |
Message to publish (string or object, supports interpolation) |
timeout |
Timeout in seconds for consume (default: 10) |
Returns:
- publish:
{ "success": true, "messageId": "...", "provider": "..." } - consume (success):
{ "success": true, "message": "...", "messageId": "...", "provider": "..." } - consume (no message):
{ "success": false, "timeout": true }
Notes:
- RabbitMQ: Requires Management Plugin enabled (default on port 15672)
- Redis: Requires HTTP API (Redis Stack) or HTTP wrapper service. Standard Redis uses binary protocol (RESP) and is not directly supported.
- AWS SQS: Requires valid AWS credentials with SQS permissions. Uses AWS Signature V4 authentication.
- Google Cloud Pub/Sub: Requires service account JSON with Pub/Sub permissions. Uses OAuth2 JWT authentication.
The pipeline editor includes intelligent autocomplete powered by Monaco Editor:
- Module suggestions — Type
"module": "to see available modules with descriptions - Parameter hints — Inside
"params": {}, get suggestions for module-specific parameters - Variable autocomplete — Type
${to see available interpolation variables - Required/optional indicators — Parameters marked as required or optional with defaults
The editor also provides Quick Insert buttons for common variables like ${prev}, ${results.}, and environment variables.
To run steps in parallel, wrap them in a nested array:
{
"steps": [
{ "module": "shell", "params": { "cmd": "echo 'Starting...'" } },
[
{ "module": "http", "params": { "url": "https://api.example.com/users" } },
{ "module": "http", "params": { "url": "https://api.example.com/posts" } },
{ "module": "http", "params": { "url": "https://api.example.com/comments" } }
],
{ "module": "shell", "params": { "cmd": "echo 'All fetched!'" } }
]
}Steps inside a nested array execute simultaneously. The pipeline waits for all parallel steps to complete before continuing to the next step.
Use dependsOn to make a step conditional on the success of previous steps:
{
"steps": [
{ "name": "build", "module": "shell", "params": { "cmd": "npm run build" } },
{ "name": "test", "module": "shell", "params": { "cmd": "npm test" } },
{
"name": "deploy",
"module": "shell",
"params": { "cmd": "./deploy.sh" },
"dependsOn": ["build", "test"]
}
]
}- If any dependency fails, the pipeline stops with an error
- Dependencies must reference steps defined before the current step
- Use a string for single dependency or array for multiple
docker compose up -d --build# Copy example config
cp env.example .env
# Edit settings
nano .env
# Start
docker compose up -dTo run pipeline steps in Docker containers:
# Create sandbox directory on host
mkdir -p /tmp/homeworkci
# Set environment variables
echo "DOCKER_ENABLED=true" >> .env
echo "SANDBOX_HOST_PATH=/tmp/homeworkci" >> .env
# Restart
docker compose up -d --buildMount local directories for live code changes:
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d| Variable | Default | Description |
|---|---|---|
PORT |
8008 |
Internal server port |
HOST |
0.0.0.0 |
Server bind address |
ENABLE_SCHEDULER |
true |
Enable cron scheduler |
SANDBOX_MAX_AGE_HOURS |
24 |
Sandbox cleanup age |
| Variable | Default | Description |
|---|---|---|
PIPELINES_DIR |
./pipelines |
Pipeline definitions |
MODULES_DIR |
./modules |
Step modules |
DATA_DIR |
./data |
SQLite database |
CONFIG_DIR |
./config |
Configuration files |
SANDBOX_DIR |
./tmp |
Temporary directories |
| Variable | Default | Description |
|---|---|---|
DOCKER_ENABLED |
false |
Enable Docker module |
SANDBOX_HOST_PATH |
— | Host path for sandbox (Docker-in-Docker) |
DOCKER_DEFAULT_IMAGE |
alpine:3.19 |
Default container image |
DOCKER_MEMORY_LIMIT |
512m |
Default memory limit |
DOCKER_CPU_LIMIT |
1 |
Default CPU limit |
DOCKER_NETWORK_DEFAULT |
bridge |
Default network mode |
DOCKER_TIMEOUT_MS |
600000 |
Container timeout (10 min) |
| Variable | Default | Description |
|---|---|---|
CLIENT_PORT |
80 |
External web interface port |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/health |
Health check |
GET |
/api/pipelines |
List all pipelines |
POST |
/api/pipelines |
Create pipeline |
GET |
/api/pipelines/:id |
Get pipeline |
PUT |
/api/pipelines/:id |
Update pipeline |
DELETE |
/api/pipelines/:id |
Delete pipeline |
POST |
/api/pipelines/:id/run |
Run pipeline |
POST |
/api/pipelines/:id/stop |
Stop running pipeline |
GET |
/api/pipelines/:id/runs |
Get pipeline runs |
GET |
/api/modules |
List available modules |
GET |
/api/modules/:name |
Get module info |
GET |
/api/variables |
Get global variables |
POST |
/api/variables |
Update global variables |
GET |
/api/environments |
List environments |
WS |
/api/ws |
WebSocket for live logs |
# Start server
deno task start
# Start frontend dev server
cd client && npm run dev
# Build frontend
cd client && npm run build
# Run linter
deno lint# Build and start
docker compose up -d --build
# View logs
docker compose logs -f
docker compose logs -f server
# Stop
docker compose down
# Stop and remove volumes
docker compose down -v
# Rebuild single service
docker compose up -d --build server
# Development mode
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d# Manual sandbox cleanup
curl -X POST http://localhost:8008/api/sandbox/cleanup
# Check health
curl http://localhost:8008/api/healthMIT