# µPaaS by [@sneak](https://sneak.berlin) 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: 1. `globals` - Build-time variables 2. `logger` - Structured logging 3. `config` - Configuration loading 4. `database` - SQLite connection + migrations 5. `healthcheck` - Health status 6. `auth` - Authentication service 7. `app` - App management 8. `docker` - Docker client 9. `notify` - Notification service 10. `deploy` - Deployment service 11. `webhook` - Webhook processing 12. `middleware` - HTTP middleware 13. `handlers` - HTTP handlers 14. `server` - 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.HandlerFunc` allowing 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](https://github.com/github/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 suite - `script/lint` — run golangci-lint - `script/fmt` — format all code (writes) - `script/fmt-check` — check formatting (read-only) - `script/check` — run test, lint, and fmt-check - `script/docker` — build the Docker image tagged via `script/projectname` - `script/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 tidy` guard, then `script/check`) - `script/install-precommit` — install the git pre-commit hook that runs `script/precommit` ## Development ### Prerequisites - Go 1.25+ - golangci-lint - Docker (for running) ### Commands ```bash 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: 1. **Format**: Run `make fmt` to format all code 2. **Lint**: Run `make lint` and fix all errors/warnings - Do not disable linters or add nolint comments without good reason - Fix the code, don't hide the problem 3. **Test**: Run `make test` and ensure all tests pass - Fix failing tests by fixing the code, not by modifying tests to pass - Add tests for new functionality 4. **Verify**: Run `make check` to confirm everything passes ```bash # 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 ```bash 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`](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: ```bash HOST_DATA_DIR=/srv/upaas/data ``` Other settings from [Configuration](#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. **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`. Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`. ## License WTFPL