An API for receiving build information and triggering Colmena changes
  • Go 56%
  • Vue 30.3%
  • TypeScript 11.3%
  • Shell 1.2%
  • Nix 1%
  • Other 0.2%
Find a file
2026-06-17 18:32:41 +00:00
.pi Update UI project to use TypeScript 2026-06-10 22:59:05 +00:00
internal Fix queries involving UUID, add deploy script for a host to UI 2026-06-17 18:32:41 +00:00
script Update database queries to use jet models 2026-06-10 17:38:18 +00:00
ui Fix queries involving UUID, add deploy script for a host to UI 2026-06-17 18:32:41 +00:00
.gitignore Add beginning of frontend and sessions 2026-06-08 23:17:51 +00:00
flake.lock Set up nix build 2026-05-31 23:25:50 +00:00
flake.nix Fix references to backend port 8080 which is now 9000 2026-06-10 23:02:55 +00:00
go.mod Add generated table and model for existing tables 2026-06-10 03:00:58 +00:00
go.sum Add generated table and model for existing tables 2026-06-10 03:00:58 +00:00
lefthook.yml Migrate to zerolog 2026-06-09 18:47:32 +00:00
main.go Add manual deployment API 2026-06-15 22:28:23 +00:00
README.md Remove references to Colmena 2026-06-15 22:35:19 +00:00
start-despacito-frontend.sh Add script for starting the frontend 2026-06-16 15:57:46 +00:00

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

  1. 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).

  2. Notify despacito. The action runner sends an HTTP POST request 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"
    }
    
  3. Update inputs. Despacito validates the request, then updates the relevant Nix flake input (e.g., bumping the my-webapp input in the deployment flake's flake.nix or flake.lock).

  4. 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 token value 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.



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 hostrepository 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 localhost redirect 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 Conflict rather 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, and log/slog as the foundation. External dependencies are added sparingly.

Development

Prerequisites

  • Go 1.22+
  • Node.js 22+
  • Nix (optional, provides a fully reproducible dev shell)

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