I have a recurring problem: I want to give someone (sometimes future-me) a button to push that runs a specific shell command on a server. A backup, a deployment dry-run, a cache flush, a database snapshot. Cron isn’t quite right because the trigger isn’t time-based. SSH is fine but assumes the person has SSH and remembers the exact command. A bespoke Flask app is too much yak-shaving.
RunRun is my attempt at the smallest reasonable thing that fills that gap: a Go web app where you declare tasks in YAML, log in, click “run”, and watch the output stream live in your browser.
The idea, in one screen
Tasks live in config.yaml. They have a name, an optional description, a list of steps, and a timeout. Steps are shell commands, executed sequentially. The web UI lists them, lets authenticated users trigger them, and streams the logs live over WebSocket.
A minimal config looks like this:
server:
port: 8080
log_level: "info"
max_concurrent_tasks: 5
session_timeout: 24h
log_directory: "./logs"
auth:
jwt_secret: "your-secret-key-at-least-32-characters-long"
users:
- username: "admin"
password: "$2a$10$YourBcryptHashedPasswordHere"
tasks:
- name: "hello-world"
description: "Simple hello world task"
tags: ["demo"]
timeout: 1m
steps:
- name: "Echo Hello"
command: "echo 'Hello, World!'"You generate the bcrypt password with the binary itself:
./runrun hash-password yourpasswordThen start it:
./runrun server --config config.yaml --port 8080Open http://localhost:8080, log in, click the task. Logs scroll live in the browser.
A more realistic task
The point of having structured tasks isn’t echo 'Hello, World!', it’s stitching together a few commands with a working directory and environment variables:
tasks:
- name: "backup-database"
description: "Backup PostgreSQL database"
tags: ["database", "backup"]
timeout: 30m
working_directory: "/var/backups"
environment:
DB_HOST: "localhost"
DB_NAME: "myapp"
steps:
- name: "Create backup directory"
command: "mkdir -p /var/backups/$(date +%Y%m%d)"
- name: "Run pg_dump"
command: "pg_dump -h $DB_HOST $DB_NAME > backup.sql"
- name: "Compress backup"
command: "gzip backup.sql"This is the sweet spot for me: one named entry point, a few clear steps, full output visible to whoever clicks the button.
What’s actually in there
A few of the things I cared about while building it:
- Auth that isn’t terrible. JWT cookies, bcrypt password hashing, session store so tokens are revocable. Cookies are HTTP-only and
SameSite=Strict. - CSRF protection on state-changing endpoints, with constant-time token comparison.
- Rate limiting on the login endpoint (default: 5 attempts per 15 minutes, IP-based).
- A worker pool so concurrent tasks are bounded; runaway shell commands can’t fork-bomb the host.
- Per-execution log files, plus a download endpoint for after-the-fact inspection.
- Health and readiness endpoints (
/health,/health/ready,/health/live) for Kubernetes.
Triggering a task from CI is also a one-liner:
curl -c cookies.txt -X POST http://localhost:8080/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"yourpass"}'
curl -b cookies.txt -X POST http://localhost:8080/tasks/my-task/executeInstalling
There’s a go install path for the impatient:
go install github.com/sgaunet/runrun/cmd/runrun@latestOr build from source — the repo has a Taskfile that handles the templ codegen and Tailwind build:
git clone https://github.com/sgaunet/runrun.git
cd runrun
task install-tools
task build-all
./runrun versionThe README also has a Dockerfile and a Kubernetes deployment example for when you want to put it somewhere real.
What it isn’t
RunRun is not a CI/CD platform. It doesn’t trigger on git push, doesn’t fan out to multiple workers, doesn’t have approvals or stages. If you need that, you already know the names. What RunRun is good at is being the smallest possible UI for “run this command on this box, with auth, and let me see the output.” That’s a niche I kept hitting, so I built the tool.
Source at github.com/sgaunet/runrun, MIT-licensed. If you’ve got a homelab or a small team and the words “I just want a button to push this” sound familiar, take it for a spin.