.dockerignore left out .git, so `make build` in the image found no git metadata and stamped `dev`. It now sends .git, and no longer leaves out tracked files (LICENSE, README.md, ...), which git would see as deleted and mark the version -dirty. The footer and /health already read the same version. The startup log line that reports it, the logger's Identify(), was never called; main now calls it. The README says to build from a git clone. Side effect: image layers after `COPY . .` now rebuild whenever `.git` changes. Disclosure: merged after a rebase that changed only TODO.md; the second review gated this same tree on this base. Model: opus-5-5
13 KiB
µ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.
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