The app page's content column is now at most 84rem wide instead of 56rem (max-w-4xl), set inline because the committed Tailwind CSS has no class for that width. The build log and container log boxes are 800px tall instead of 400px. The build log section moves to between the webhook URL and the environment variables, and the container log section moves to directly above the deploy key; nothing else moves. A new handler test renders the app page and checks the width, the section order and both log heights. Disclosure: merged after a rebase that changed only TODO.md; the review gated this change on the next before #234, which touches no template. Model: opus-5-5 Co-authored-by: clawbot <sneak+clawbot@sneak.cloud>
µPaaS by @sneak
A simple self-hosted PaaS that auto-deploys Docker containers from Git repositories via webhooks from Gitea, GitHub, or GitLab.
Features
- Single admin user with argon2id password hashing
- Per-app SSH keypairs for read-only deploy keys
- Per-app UUID-based webhook URLs with auto-detection of Gitea, GitHub, and GitLab
- Branch filtering - only deploy on configured branch changes
- Environment variables, labels, and volume mounts per app
- CPU and memory resource limits per app
- Docker builds via socket access
- Notifications via ntfy and Slack-compatible webhooks
- Simple server-rendered UI with Tailwind CSS
Non-Goals
- Multi-user support
- Complex CI pipelines
- Multiple container orchestration
- SPA/API-first design
- Support for non-push webhook events (e.g. issues, merge requests)
Architecture
Project Structure
upaas/
├── cmd/upaasd/ # Application entry point
├── internal/
│ ├── config/ # Configuration via Viper
│ ├── database/ # SQLite database with migrations
│ ├── docker/ # Docker client for builds/deploys
│ ├── globals/ # Build-time variables (version, etc.)
│ ├── handlers/ # HTTP request handlers
│ ├── healthcheck/ # Health status service
│ ├── logger/ # Structured logging (slog)
│ ├── middleware/ # HTTP middleware (auth, logging, CORS)
│ ├── models/ # Active Record style database models
│ ├── server/ # HTTP server and routes
│ ├── service/
│ │ ├── app/ # App management service
│ │ ├── auth/ # Authentication service
│ │ ├── deploy/ # Deployment orchestration
│ │ ├── notify/ # Notifications (ntfy, Slack)
│ │ └── webhook/ # Webhook processing (Gitea, GitHub, GitLab)
│ └── ssh/ # SSH key generation
├── static/ # Embedded CSS/JS assets
└── templates/ # Embedded HTML templates
Dependency Injection
Uses Uber fx for dependency injection. Components are wired in this order:
globals- Build-time variableslogger- Structured loggingconfig- Configuration loadingdatabase- SQLite connection + migrationshealthcheck- Health statusauth- Authentication serviceapp- App managementdocker- Docker clientnotify- Notification servicedeploy- Deployment servicewebhook- Webhook processingmiddleware- HTTP middlewarehandlers- HTTP handlersserver- HTTP server
Request Flow
HTTP Request
│
▼
chi Router ──► Middleware Stack ──► Handler
│
(Logging, Auth, CORS, etc.)
│
▼
Handler Function
│
▼
Service Layer (app, auth, deploy, etc.)
│
▼
Models (Active Record)
│
▼
Database
Key Patterns
- Closure-based handlers: Handlers return
http.HandlerFuncallowing one-time initialization - Active Record models: Models encapsulate database operations (
Save(),Delete(),Reload()) - Async deployments: Webhook triggers deploy via goroutine with
context.WithoutCancel() - Embedded assets: Templates and static files embedded via
//go:embed
Entrypoints
This repository adheres to the
Scripts to Rule Them All
standard: normalized scripts in script/ are the entrypoints for the
development workflow, and the Makefile targets are thin shims that call them. We
provide:
script/bootstrap— install all dependencies (idempotent)script/setup— make a fresh clone ready for development (bootstrap, then install-precommit)script/projectname— output the project name ("upaas")script/test— run the test suitescript/lint— run golangci-lintscript/fmt— format all code (writes)script/fmt-check— check formatting (read-only)script/check— run test, lint, and fmt-checkscript/docker— build the Docker image tagged viascript/projectnamescript/cibuild— CI entrypoint:docker build .(the Dockerfile runs the checks, so a green build implies a green repo)script/precommit— pre-commit checks (go mod tidyguard, thenscript/check)script/install-precommit— install the git pre-commit hook that runsscript/precommit
Development
Prerequisites
- Go 1.25+
- golangci-lint
- Docker (for running)
Commands
make bootstrap # Install all dependencies (idempotent)
make setup # Bootstrap + install git pre-commit hook
make fmt # Format code
make fmt-check # Check formatting (read-only, fails if unformatted)
make lint # Run comprehensive linting
make test # Run tests with race detection (30s timeout)
make check # Verify everything passes (test, lint, fmt-check)
make build # Build binary
make docker # Build Docker image
make hooks # Install pre-commit hook (runs script/precommit)
Commit Requirements
All commits must pass make check before being committed.
Before every commit:
- Format: Run
make fmtto format all code - Lint: Run
make lintand fix all errors/warnings- Do not disable linters or add nolint comments without good reason
- Fix the code, don't hide the problem
- Test: Run
make testand ensure all tests pass- Fix failing tests by fixing the code, not by modifying tests to pass
- Add tests for new functionality
- Verify: Run
make checkto confirm everything passes
# Standard workflow before commit:
make fmt
make lint # Fix any issues
make test # Fix any failures
make check # Final verification
git add .
git commit -m "Your message"
The Docker build runs make check and will fail if:
- Code is not formatted
- Linting errors exist
- Tests fail
- Code doesn't compile
This ensures the main branch always contains clean, tested, working code.
Configuration
Environment variables:
| Variable | Description | Default |
|---|---|---|
PORT |
HTTP listen port. UPAAS_PORT is also read and wins when both are set. |
8080 |
UPAAS_DATA_DIR |
Directory for the SQLite database, session key, builds and deployment logs. Deploys need it to be an absolute path unless UPAAS_HOST_DATA_DIR is set. |
./data (the Docker image sets /var/lib/upaas) |
UPAAS_HOST_DATA_DIR |
Host path of UPAAS_DATA_DIR, needed when upaas runs in a container so the bind mounts it passes to Docker point at the right host directory. When set, it must be absolute, or upaas refuses to start. |
the value of UPAAS_DATA_DIR |
UPAAS_DOCKER_HOST |
Docker daemon address | unix:///var/run/docker.sock |
UPAAS_PLAINTEXT_HTTP |
Set when µPaaS is reached over plain HTTP (no TLS-terminating proxy in front) so CSRF origin checks use http://. Leave unset behind a TLS-terminating reverse proxy. |
false |
UPAAS_DEBUG |
Enable debug logging. Also sends the session cookie without the Secure flag. |
false |
UPAAS_SENTRY_DSN |
Read but not used: upaas sends nothing to Sentry | "" |
UPAAS_METRICS_USERNAME |
When set, /metrics is served behind basic auth with this username. When unset, there is no /metrics. |
"" |
UPAAS_METRICS_PASSWORD |
Basic auth password for /metrics |
"" |
UPAAS_MAINTENANCE_MODE |
Only shown as maintenanceMode in the /health response; it blocks nothing |
false |
UPAAS_SESSION_SECRET |
Key that signs the session and CSRF cookies. When unset, a random key is generated once and kept in $UPAAS_DATA_DIR/session.key. |
"" |
UPAAS_CORS_ORIGINS |
Comma-separated origins allowed to make cross-origin requests with cookies. When unset, no CORS headers are sent. | "" |
The Docker client also reads the standard DOCKER_API_VERSION,
DOCKER_CERT_PATH and DOCKER_TLS_VERIFY variables; UPAAS_DOCKER_HOST, which
has a default, always overrides DOCKER_HOST.
Running with Docker
docker run -d \
-p 8080:8080 \
-v /var/run/docker.sock:/var/run/docker.sock \
-v /path/on/host/upaas-data:/var/lib/upaas \
-e UPAAS_HOST_DATA_DIR=/path/on/host/upaas-data \
-e UPAAS_PLAINTEXT_HTTP=true \
upaas
This recipe serves plain HTTP, so UPAAS_PLAINTEXT_HTTP=true is required for
setup and every other form to pass the CSRF origin check. Behind a
TLS-terminating reverse proxy, drop that line.
The image shows the commit it was built from (the git describe output) in the
page footer, the startup log and /health. Build it from a git clone: without
the .git directory it shows dev.
Deploying with Docker Compose
docker-compose.yml builds the image from this repo and
runs it with the Docker socket and the data directory mounted. It reads its
settings from a .env file next to it, which needs at least:
HOST_DATA_DIR=/srv/upaas/data
Other settings from Configuration go in the same file, except
PORT, UPAAS_PORT and UPAAS_DATA_DIR: the compose file sets both port
settings to 8080 and UPAAS_DATA_DIR to /var/lib/upaas, overriding .env, to
match its port mapping, healthcheck and data directory mount. Then run
docker compose up -d from the repo root; docker compose ps shows the
container as healthy once /health answers. To update, run git pull and then
docker compose up -d --build: without --build, Compose keeps running the
image built from the old checkout.
Important: HOST_DATA_DIR must be an absolute path on the host. It
is bind-mounted into the container and passed as UPAAS_HOST_DATA_DIR so that
Docker bind mounts during builds resolve correctly, because the Docker daemon
resolves paths on the host, not in the container. upaas refuses to start when
UPAAS_HOST_DATA_DIR is a relative path such as ./data.
The port is published on 127.0.0.1:8080 only, for a TLS-terminating reverse
proxy in front of it. Leave UPAAS_PLAINTEXT_HTTP unset behind that proxy.
Apps are built with BuildKit, so the stages of a multi-stage build are kept in
Docker's build cache rather than as untagged images. Docker Engine 28.2 and
later keeps that cache under a size limit by default; on older engines, set
"builder": {"gc": {"enabled": true}} in the host's daemon.json.
Building with BuildKit needs Docker Engine 18.09 or later; on an older engine,
upaas fails the deploy instead of building. A Dockerfile that uses
RUN --network needs Docker Engine 23.0 or later unless its # syntax= line
names Dockerfile frontend 1.3 or later, such as docker/dockerfile:1.
Session secrets are automatically generated on first startup and persisted to
$UPAAS_DATA_DIR/session.key.
License
WTFPL