- Go 56%
- Vue 30.3%
- TypeScript 11.3%
- Shell 1.2%
- Nix 1%
- Other 0.2%
| .pi | ||
| internal | ||
| script | ||
| ui | ||
| .gitignore | ||
| flake.lock | ||
| flake.nix | ||
| go.mod | ||
| go.sum | ||
| lefthook.yml | ||
| main.go | ||
| README.md | ||
| start-despacito-frontend.sh | ||
despacito
A bridge between Forgejo Actions and Nix deployments — the steady, deliberate heartbeat of your continuous delivery pipeline.
Overview
despacito is a lightweight Go service that sits between your Forgejo CI runners and your Nix-based deployment hosts. It receives build-completion notifications from Forgejo Actions, updates Nix flake inputs, and triggers per-host deployment scripts — slowly, carefully, and reliably.
┌─────────────────┐ HTTP POST ┌──────────────┐ shell out ┌──────────────────┐
│ Forgejo Action │ ──────────────────► │ despacito │ ──────────────► │ Deploy Scripts │
│ Runner │ (JSON payload) │ │ │ (per host) │
└─────────────────┘ └──────┬───────┘ └──────────────────┘
│
│ read/write
▼
┌──────────────┐
│ Nix Flake │
│ (flake.nix) │
└──────────────┘
How It Works
-
Build & Push. A Forgejo Action builds a Nix package and pushes the closure to Attic (a Nix binary cache). On success, the runner obtains a Nix package identifier (e.g., a store path or flake reference).
-
Notify despacito. The action runner sends an HTTP
POSTrequest to the despacito endpoint with a JSON body describing what was built:{ "package": "my-webapp", "version": "1.4.2", "nix_store_path": "/nix/store/abc123...-my-webapp-1.4.2", "flake_ref": "github:myorg/my-webapp/v1.4.2" } -
Update inputs. Despacito validates the request, then updates the relevant Nix flake input (e.g., bumping the
my-webappinput in the deployment flake'sflake.nixorflake.lock). -
Deploy. Despacito invokes each target host's configured deploy script (default:
despacito-deploy), which evaluates the updated flake and deploys the new package to the target server.
Project Architecture
despacito/
├── main.go # Entry point: config, DB, OIDC, server startup
│
├── ui/ # Vue 3 frontend (Vite)
│ ├── src/
│ │ ├── main.js # Vue app bootstrap
│ │ └── App.vue # Root component
│ ├── index.html
│ ├── package.json
│ └── vite.config.js
│
├── internal/
│ ├── config/
│ │ └── config.go # Environment variable parsing
│ │
│ ├── database/
│ │ ├── database.go # PostgreSQL connection pool
│ │ ├── migrate.go # Goose migration runner
│ │ ├── tokens.go # Auth token CRUD (Bearer tokens)
│ │ ├── sessions.go # Session & user management (OIDC)
│ │ ├── errors.go # Sentinel errors
│ │ └── migrations/ # SQL migration files
│ │
│ ├── oidc/
│ │ └── oidc.go # OIDC provider discovery & setup
│ │
│ ├── server/
│ │ ├── server.go # HTTP mux, routing, SPA handler
│ │ ├── middleware.go # Auth (Bearer + session), logging, recovery
│ │ ├── handler.go # Webhook + token management endpoints
│ │ └── auth.go # OIDC login / callback / logout
│ │
│ ├── static/
│ │ ├── embed.go # Embeds compiled Vue frontend
│ │ └── dist/ # Vite build output (populated at build time)
│ │
│ ├── webhook/
│ │ ├── types.go # Request/response types
│ │ └── validate.go # Payload validation
│ │
│ └── version/
│ └── version.go # Build-time git commit injection
│
├── go.mod
├── go.sum
├── flake.nix # Nix build for Go backend + Vue frontend
└── README.md
Authentication
despacito uses a two-tier authentication model:
1. Bearer Tokens (API / webhook access)
API tokens are stored in the database (auth_token table). Generate them via the POST /api/tokens endpoint.
Bootstrap: When no tokens exist in the database, POST /api/tokens is open to anyone (no auth required) so you can create your first token. Once any token exists, the endpoint requires authentication (Bearer token or OIDC session).
2. OIDC Sessions (browser / UI access)
Users sign in through an OpenID Connect provider. On successful login, a session is created and stored in a secure HTTP-only cookie. The session can be used to authenticate to the UI and to the token management API.
API Reference
All endpoints under /api/ require authentication via a Bearer token or OIDC session cookie,
unless noted otherwise. Refer to the Authentication section for details.
Error responses follow a consistent format: {"error": "description"} with an appropriate
HTTP status code (400, 401, 403, 404, 500).
Authentication
GET /api/oidc/providers
Public. Lists configured OIDC providers so the frontend can render login buttons.
{
"providers": [
{"issuer": "https://accounts.example.com", "name": "accounts.example.com"}
]
}
GET /auth/login?issuer=<url>
Initiates the OIDC flow. Redirects the browser to the chosen provider's authorization URL.
Accepts ?issuer= to select a specific provider.
GET /auth/callback
Handles the OIDC provider's redirect. Validates CSRF state, exchanges the authorization code,
verifies the ID token, upserts the user, creates a session, and redirects to /.
POST /auth/logout
Revokes the current session and clears the session cookie.
Session & API tokens
GET /api/me
Returns the currently authenticated principal.
Session-authenticated:
{
"auth_method": "session",
"user": {
"id": "uuid",
"name": "Jane Doe",
"email": "jane@example.com",
"avatar_url": "https://www.gravatar.com/avatar/..."
}
}
Bearer-token (with associated user):
{
"auth_method": "bearer",
"user": { "id": "...", "name": "...", "email": "...", "avatar_url": "..." }
}
Bearer-token (no associated user):
{
"auth_method": "bearer",
"token": { "id": "uuid", "description": "ci-runner-1" }
}
POST /api/tokens
Creates a new Bearer token. Requires authentication, unless no tokens exist yet (bootstrap mode — open when the database has zero tokens).
Request:
{
"description": "ci-runner-1",
"expires_in": "720h"
}
| Field | Required | Notes |
|---|---|---|
description |
yes | Human-readable label |
expires_in |
no | Go duration string (e.g. "720h", "30d"). Omit for no expiry. |
Response (201):
{
"id": "uuid",
"token": "64-char-hex-string",
"description": "ci-runner-1",
"created_at": "2026-06-08T12:00:00Z",
"expires_at": "2026-07-08T12:00:00Z"
}
The
tokenvalue is only returned once — store it securely.
GET /api/tokens
Lists all tokens belonging to the authenticated user. Session-auth only.
{
"tokens": [
{
"id": "uuid",
"uri": "/api/tokens/uuid",
"description": "ci-runner-1",
"created_at": "...",
"last_used_at": "...",
"expires_at": null,
"revoked_at": null,
"is_active": true
}
]
}
DELETE /api/tokens/{id}
Revokes a token (sets is_active = false, records revoked_at). Session-auth only.
Returns 204 No Content. Rejects tokens not owned by the caller with 403.
Repositories
POST /api/repository
Creates a repository.
Request:
{ "name": "my-webapp", "url": "https://git.example.com/my-webapp" }
Response (201):
{
"id": "uuid",
"name": "my-webapp",
"url": "https://git.example.com/my-webapp",
"created_at": "2026-06-10T12:00:00Z",
"updated_at": "2026-06-10T12:00:00Z"
}
GET /api/repository
Lists all non-deleted repositories, ordered by name.
{
"repositories": [
{
"id": "uuid",
"uri": "/api/repository/uuid",
"name": "my-webapp",
"url": "...",
"created_at": "...",
"updated_at": "..."
}
]
}
Each item includes a uri field for use with GET/PUT/DELETE on that resource.
GET /api/repository/{id}
Returns a single repository.
PUT /api/repository/{id}
Updates a repository's name and/or url. At least one field is required.
Request:
{ "name": "renamed-app" }
DELETE /api/repository/{id}
Soft-deletes a repository (sets deleted_at). Returns 204.
Builds
POST /api/build
Creates a build. References the repository by URI.
Request:
{
"repository_uri": "/api/repository/uuid",
"commit_id": "abc123def",
"status": "success"
}
| Field | Required | Default |
|---|---|---|
repository_uri |
yes | |
commit_id |
yes | |
status |
no | "pending" |
GET /api/build
Lists all non-deleted builds, newest first.
{
"builds": [
{
"id": "uuid",
"uri": "/api/build/uuid",
"repository_uri": "/api/repository/repo-uuid",
"commit_id": "abc123",
"status": "pending",
"created_at": "...",
"updated_at": "..."
}
]
}
GET /api/build/{id}
Returns a single build.
PUT /api/build/{id}
Updates commit_id and/or status. At least one field required.
{ "status": "failed" }
DELETE /api/build/{id}
Soft-deletes a build. Returns 204.
Hosts
POST /api/host
Creates a deployment target.
Request:
{
"hostname": "web01.dc1.example.com",
"url": "ssh://...",
"deploy_script": "/usr/local/bin/my-deploy"
}
| Field | Required | Default | Description |
|---|---|---|---|
hostname |
yes | — | Hostname of the target machine |
url |
yes | — | Connection URL (e.g. ssh://...) |
deploy_script |
no | despacito-deploy |
Program to run for deployments. A bare name is resolved via PATH; an absolute path is used directly. |
GET /api/host
Lists all non-deleted hosts, ordered by hostname.
{
"hosts": [
{
"id": "uuid",
"uri": "/api/host/uuid",
"hostname": "web01.dc1.example.com",
"url": "ssh://...",
"deploy_script": "/usr/local/bin/my-deploy",
"created_at": "...",
"updated_at": "..."
}
]
}
GET /api/host/{id}
Returns a single host.
PUT /api/host/{id}
Updates hostname, url, and/or deploy_script. At least one field is required. Pass null for deploy_script to clear it (revert to the default).
DELETE /api/host/{id}
Soft-deletes a host. Returns 204.
Deployments
POST /api/deployment
Triggers a manual deployment on a specific host — no build required.
Request:
{ "host_uri": "/api/host/uuid" }
Response (201):
{
"id": "uuid",
"uri": "/api/deployment/uuid",
"host_uri": "/api/host/uuid",
"hostname": "web01.dc1.example.com",
"manual": true,
"status": "pending",
"deploy_script": "/usr/local/bin/my-deploy",
"created_at": "2026-06-12T12:00:00Z"
}
The deployment is queued for the background worker and executed asynchronously.
The DESPACITO_MANUAL=true environment variable is set on the deploy script
so it can distinguish manual runs from build-triggered ones.
Host–repository links
POST /api/host-repository
Links a host to a repository. Both entities are referenced by URI.
Request:
{
"host_uri": "/api/host/uuid",
"repository_uri": "/api/repository/uuid"
}
Returns 201 with the same URIs.
GET /api/host-repository
Lists all host–repository links.
{
"host_repositories": [
{ "host_uri": "/api/host/uuid", "repository_uri": "/api/repository/uuid" }
]
}
DELETE /api/host-repository
Removes a link. Identified by query parameters (the join table has no standalone ID):
DELETE /api/host-repository?host_uri=/api/host/uuid&repository_uri=/api/repository/uuid
Returns 204.
Dashboard
GET /api/dashboard
Returns aggregate counts and recent activity for the main dashboard page.
Response (200):
{
"counts": {
"hosts": 3,
"repositories": 5,
"ongoing_deployments": 1,
"failed_deployments_24h": 2
},
"recent_builds": [
{
"id": "uuid",
"repository_name": "my-webapp",
"commit_id": "abc123def",
"status": "success",
"duration_ms": 45000,
"created_at": "2026-06-11T12:00:00Z"
}
],
"recent_deployments": [
{
"id": "uuid",
"hostname": "web01.dc1.example.com",
"repository_name": "my-webapp",
"commit_id": "abc123def",
"status": "success",
"duration_ms": 120000,
"started_at": "2026-06-11T12:05:00Z"
}
]
}
| Field | Type | Description |
|---|---|---|
counts.hosts |
int | Total non-deleted hosts |
counts.repositories |
int | Total non-deleted repositories |
counts.ongoing_deployments |
int | Deployments with no finished_at (in progress) |
counts.failed_deployments_24h |
int | Deployments with status "failed" created in the last 24 hours |
recent_builds |
array | Up to 10 most recent non-deleted builds, newest first |
recent_builds[].id |
uuid | Build ID |
recent_builds[].repository_name |
string | Name of the source repository |
recent_builds[].commit_id |
string | Commit that triggered the build |
recent_builds[].status |
string | Build status ("pending", "success", "failed") |
recent_builds[].duration_ms |
int|null | Wall-clock time from build creation to last update, in milliseconds. null when no meaningful duration can be reported |
recent_builds[].created_at |
datetime | When the build was created |
recent_deployments |
array | Up to 10 most recent deployments, newest first |
recent_deployments[].id |
uuid | Deployment ID |
recent_deployments[].hostname |
string | Hostname of the target machine |
recent_deployments[].repository_name |
string | Name of the deployed repository |
recent_deployments[].commit_id |
string | Commit that was deployed |
recent_deployments[].status |
string | Deployment status ("pending", "deploying", "success", "failed") |
recent_deployments[].duration_ms |
int|null | Wall-clock time from started_at to finished_at, in milliseconds. null when the deployment has not yet finished |
recent_deployments[].started_at |
datetime|null | When the deployment began (null if not yet started) |
Webhook
POST /webhook
Called by Forgejo action runners when a build completes. Creates a build record in the database, looked up by repository name.
Headers:
| Header | Value |
|---|---|
Content-Type |
application/json |
Authorization |
Bearer <token> |
Request:
{
"repository": "my-webapp",
"version": "1.4.2",
"nix_store_path": "/nix/store/abc123...-my-webapp-1.4.2",
"flake_ref": "github:myorg/my-webapp/v1.4.2",
"commit_id": "abc123def456",
"build_time_ms": 45000,
"run_id": "12345",
"ref": "refs/heads/main"
}
| Field | Required | Type | Description |
|---|---|---|---|
repository |
yes | string | Repository name — must match an existing non-deleted repository |
version |
yes | string | Semantic version that was built |
nix_store_path |
yes | string | Nix store path pushed to Attic |
commit_id |
yes | string | Git commit hash that was built |
flake_ref |
no | string | Flake reference (e.g. github:owner/repo/v1.2.3) |
build_time_ms |
no | int | Total wall-clock build time in milliseconds |
run_id |
no | string | Forgejo Actions run identifier |
ref |
no | string | Branch or tag ref (e.g. refs/heads/main) |
Response (200):
{
"status": "created",
"build_id": "550e8400-e29b-41d4-a716-446655440000"
}
Errors:
| Status | Cause |
|---|---|
| 400 | Missing required field, invalid JSON, or unknown repository name |
| 401 | Missing or invalid Bearer token |
| 500 | Database error recording the build |
Health
GET /health
Always open (no auth required). Returns {"status":"ok"}.
Configuration
despacito is configured entirely via environment variables:
| Variable | Required | Default | Description |
|---|---|---|---|
DESPACITO_LISTEN |
No | :9000 |
TCP address to listen on (ignored if DESPACITO_LISTEN_SOCKET is set) |
DESPACITO_LISTEN_SOCKET |
No | — | Unix domain socket path (takes precedence over DESPACITO_LISTEN) |
DESPACITO_BASE_URL |
No | — | Public base URL (e.g. https://despacito.example.com). Used for building redirect URIs and OIDC callback URLs. |
DESPACITO_FLAKE_DIR |
No | — | Path to the deployment flake directory (optional for now) |
DESPACITO_NIXOS_CONFIG_DIR |
No | — | Path to the NixOS configuration flake. When set, the worker runs nix flake lock and each host's deploy script from this directory. Deployments are skipped entirely when this is unset. |
DESPACITO_LOG_LEVEL |
No | info |
Log level (debug, info, warn, error) |
DESPACITO_POSTGRES_CONNECTION_URI |
No | — | PostgreSQL connection string. Required for authentication and persistence. |
DESPACITO_SESSION_COOKIE_SECURE |
No | true |
Set the Secure flag on session cookies. Set to false for local development without TLS. |
Deploy script environment
When the worker invokes a host's deploy script, the following environment variables are set on the child process (in addition to the parent's environment):
| Variable | Always set? | Description |
|---|---|---|
DESPACITO_BUILD_ID |
yes | UUID of the build that triggered this deployment |
DESPACITO_DEPLOYMENT_ID |
yes | UUID of this specific deployment row |
DESPACITO_REPOSITORY |
yes | Repository name |
DESPACITO_COMMIT_ID |
yes | Git commit hash that was built |
DESPACITO_HOSTNAME |
yes | Hostname of the target machine |
DESPACITO_REF |
if present on build | Branch or tag ref (e.g. refs/heads/main) |
DESPACITO_NIX_STORE_PATH |
if present on build | Nix store path pushed to Attic |
DESPACITO_MANUAL |
manual deployments only | Set to true when the deployment was initiated via POST /api/deployment |
OIDC Configuration
When OIDC is enabled, users can sign in through the web UI. You must register an OAuth2/OIDC application with your identity provider and configure these variables:
| Variable | Required | Description |
|---|---|---|
DESPACITO_OIDC_CLIENT_ID |
Yes (for OIDC) | OAuth2 client ID from your provider |
DESPACITO_OIDC_CLIENT_SECRET |
Yes (for OIDC) | OAuth2 client secret from your provider |
DESPACITO_OIDC_REDIRECT_URL |
Yes (for OIDC) | Callback URL, e.g. https://despacito.example.com/auth/callback |
DESPACITO_OIDC_ISSUER_URLS |
Yes (for OIDC) | Comma-separated list of OIDC issuer URLs. Multiple providers are supported — the frontend will show a login button for each. |
Example: Forgejo as an OIDC provider
Create an OAuth2 application in your Forgejo instance at Settings → Applications → Manage OAuth2 Applications. Set the redirect URI to https://despacito.example.com/auth/callback.
Then configure despacito:
export DESPACITO_POSTGRES_CONNECTION_URI="postgres://despacito:secret@localhost:5432/despacito?sslmode=disable"
export DESPACITO_OIDC_ISSUER_URLS="https://git.example.com"
export DESPACITO_OIDC_CLIENT_ID="your-client-id"
export DESPACITO_OIDC_CLIENT_SECRET="your-client-secret"
export DESPACITO_OIDC_REDIRECT_URL="https://despacito.example.com/auth/callback"
export DESPACITO_BASE_URL="https://despacito.example.com"
export DESPACITO_SESSION_COOKIE_SECURE="true"
Example: Multiple providers
export DESPACITO_OIDC_ISSUER_URLS="https://git.example.com, https://accounts.google.com"
Each provider's hostname is shown on the login button in the UI.
Example: Local development (no TLS)
export DESPACITO_SESSION_COOKIE_SECURE="false"
export DESPACITO_OIDC_REDIRECT_URL="http://localhost:9000/auth/callback"
export DESPACITO_BASE_URL="http://localhost:9000"
Note: Most OIDC providers require HTTPS for production. Some (like Forgejo) allow
localhostredirect URIs for development.
Bootstrapping the first API token
On first run with an empty database, create your initial Bearer token:
curl -X POST http://localhost:9000/api/tokens \
-H "Content-Type: application/json" \
-d '{"description": "initial-admin-token"}'
This endpoint is open when no tokens exist. After creating your first token, all subsequent calls to POST /api/tokens require authentication.
Alternatively, sign in through the web UI via OIDC, then create tokens from an authenticated session.
Design Decisions
- One deployment at a time (by default). Deployments are processed sequentially by the background worker to avoid conflicting flake updates and deployment races.
- Idempotent. If a version is already deployed, despacito returns
409 Conflictrather than re-deploying unnecessarily. - Database-backed tokens. Auth tokens are stored in PostgreSQL, not a single shared secret. Tokens can be created, revoked, and expired individually.
- OIDC for humans. Browser-based access uses OpenID Connect with session cookies. API/webhook access uses Bearer tokens.
- Asynchronous deployment. The webhook returns immediately after recording the build; the background worker picks up the event and runs each host's deploy script.
- Go standard library first. Uses
net/http,encoding/json, andlog/slogas the foundation. External dependencies are added sparingly.
Development
Prerequisites
Quick start with Nix
nix develop
# Drops you into a shell with go, node, and npm.
Backend (Go)
go build ./...
./despacito
Frontend (Vue 3)
cd ui
npm install
npm run dev # Vite dev server with hot reload, proxies API to :9000
Open http://localhost:5173 in your browser. The Vite dev server proxies /api, /auth, /webhook, and /health requests to the Go backend on port 9000.
Building the frontend for embedding
cd ui
npm run build # outputs to ui/dist/
The production build compiles the frontend into the Go binary. When running the binary, the embedded SPA is served automatically.
Full Nix build
nix build
This builds both the Vue frontend and the Go backend, embedding the compiled frontend into the resulting despacito binary.
Running the complete stack
# 1. Start a PostgreSQL database (e.g. via Docker or NixOS)
# 2. Set the required environment variables
export DESPACITO_POSTGRES_CONNECTION_URI="postgres://despacito:secret@localhost:5432/despacito?sslmode=disable"
export DESPACITO_OIDC_ISSUER_URLS="https://git.example.com"
export DESPACITO_OIDC_CLIENT_ID="your-client-id"
export DESPACITO_OIDC_CLIENT_SECRET="your-client-secret"
export DESPACITO_OIDC_REDIRECT_URL="https://despacito.example.com/auth/callback"
# 3. Run the server
./despacito
# Or with a Unix domain socket (for use behind Caddy or another reverse proxy)
export DESPACITO_LISTEN_SOCKET=/run/despacito/webhook.sock
./despacito
Reverse proxy with Caddy
despacito.example.com {
reverse_proxy unix//run/despacito/webhook.sock
}
Note: When using a Unix socket, the socket file is automatically removed on shutdown. Stale socket files from a previous crashed run are cleaned up on startup.
Database setup
despacito uses goose for schema migrations. Migrations run automatically on startup if a database connection URI is configured. Tables created:
| Table | Purpose |
|---|---|
user |
OIDC-authenticated users |
session |
OIDC login sessions |
auth_token |
Bearer tokens for API/webhook access |
repository |
Source repositories that get built by CI |
build |
Completed CI builds of a repository |
host |
Target machines that receive deployments |
host_repository |
Which repositories are deployed to which hosts |
deployment |
Deployment of a specific build onto a specific host |
Testing the webhook
curl -X POST http://localhost:9000/webhook \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <token-from-api/tokens>" \
-d '{
"package": "my-webapp",
"version": "1.4.2",
"nix_store_path": "/nix/store/abc123-my-webapp-1.4.2"
}'
License
TBD