Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 9 additions & 14 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,28 +93,21 @@ All five build packs produce long-lived containers with health checks and automa

## Storage & Routing

### Object Storage
### Volume-Based Storage

S3-compatible blob storage for deployed artifacts. Abstracted behind an `uploadToObjectStorage()` interface using the AWS S3 SDK with `forcePathStyle: true`. Garage for local dev; Garage (self-hosted) or Cloudflare R2 for production. No hardcoded S3-specific logic.
A shared Docker named volume (`shipyard_sites`) mounted in the worker and Caddy containers. Each deployment's build output lives at `sites/{appId}/{deploymentId}/`. A symlink at `sites/{appId}/current` points to the active deployment. Caddy's `file_server` root is permanently `sites/{appId}/current`.

### S3 Path Structure
### Symlink Activation

`/users/{userId}/apps/{appId}/deployments/{deploymentId}/{filepath}`
A symlink at `sites/{appId}/current` points to the currently-active deployment's artifact directory. Swap the symlink atomically on deploy or rollback — no Caddy API call needed. Caddy root is set once at first deploy and never changes.

### Deployment Retention

The newest 5 deployments per App are kept in object storage. Older deployments are deleted automatically after each successful deploy. Enables rollback without unlimited storage growth.

### Deployment Retention Rules

- Keep metadata in database forever (deployments table, build_jobs table, deployment_logs)
- Delete files from object storage only (after successful deploy)
- Check before deleting: if deployment.id == app.active_deployment_id, skip delete
- Deletion is async background job (queued after deploy succeeds)
After each successful deploy, the newest `KEEP_COUNT` (default 5) deployment subdirectories per App are kept. Older directories are deleted. Building deployments are excluded from pruning. Pruned deployments have `prunedAt` set in the database for queryable rollback eligibility.

### Active Deployment

The currently-live Deployment for an App, referenced by `active_deployment_id` in the App record. Rollback changes this pointer without modifying object storage.
The currently-live Deployment for an App, referenced by `active_deployment_id` in the App record. Rollback changes this pointer AND swaps the `current` symlink to the target deployment's artifact directory. See ADR-0014.

### SPA Fallback

Expand All @@ -124,6 +117,8 @@ Every `404` response from Caddy returns `index.html`, enabling client-side routi

Dynamic Caddy configuration via JSON API at deploy time (not at request time). Config is sent to Caddy's `/config/` API endpoint. After update, Caddy automatically reloads. Built-in auto-HTTPS with Let's Encrypt for wildcard certs.

For static sites, Caddy config is set once at first deploy (root: `sites/{appId}/current`). Subsequent deploys and rollbacks update the symlink — no Caddy API call needed. See ADR-0014.

## Infrastructure Philosophy

### Deployment Control Plane
Expand All @@ -142,7 +137,7 @@ Redis-backed job queue via BullMQ. A Job contains only the `deployment_id`; all

### Worker

A Node.1.js process that connects to BullMQ, picks up Jobs, executes the build pipeline, updates Deployment status in the database, and regenerates the Caddy config. Stateless per-job execution.
A Node.js process that connects to BullMQ, picks up Jobs, executes the build pipeline, updates Deployment status in the database, and generates Caddy config on first deploy (static sites) or on every deploy (server containers). Stateless per-job execution.

### Heartbeat

Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,9 @@ services:
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-32_char_encryption_key_here_xxxx}
NODE_ENV: ${NODE_ENV:-production}
PORT: ${PORT:-3000}
SITES_DIR: /var/lib/shipyard/sites
volumes:
- shipyard_sites:/var/lib/shipyard/sites:ro
depends_on:
postgres:
condition: service_healthy
Expand Down
14 changes: 7 additions & 7 deletions docs/adr/0006-static-caddy-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Status

Accepted (updated)
Accepted (updated by ADR-0014)

## Context

Expand Down Expand Up @@ -30,19 +30,19 @@ POST /config/apps/http/servers/srv0/routes → create new route

Static config generation at deploy time:

1. Deployment succeeds (or rollback triggered)
2. Read `active_deployment_id` from DB
3. Generate Caddy JSON config:
1. First deploy: generate and send Caddy JSON config (replaces any previous route for `app-{appId}`)
2. Subsequent deploys: Caddy root is already `sites/{appId}/current` — no Caddy API call needed for static sites
3. Rollback: symlink swap (per ADR-0014) — no Caddy API call at all

**Static sites (isStatic=true):**
```json
{
"@id": "app-{appId}",
"match": [{"host": ["myapp.bigboss.dev"]}],
"handle": [
{"handler": "file_server", "root": "/var/lib/shipyard/sites/{appId}", "pass_thru": true},
{"handler": "file_server", "root": "/var/lib/shipyard/sites/{appId}/current", "pass_thru": true},
{"handler": "rewrite", "uri": "/index.html"},
{"handler": "file_server", "root": "/var/lib/shipyard/sites/{appId}"}
{"handler": "file_server", "root": "/var/lib/shipyard/sites/{appId}/current"}
],
"terminal": true
}
Expand All @@ -68,7 +68,7 @@ The `pass_thru` on the first `file_server` lets unmatched requests fall through
## Consequences

- Zero latency overhead per request (no DB query, no subrequest).
- Rollback is instant from user perspective (config reload is ~milliseconds).
- Rollback is instant from user perspective (symlink swap, no Caddy interaction per ADR-0014).
- Adding/removing apps requires config regeneration + reload (acceptable for low-write system).
- Adding custom domains later requires regenerating all configs (known trade-off, acceptable).
- No need for OpenResty or Lua — plain Caddy works.
Expand Down
28 changes: 20 additions & 8 deletions docs/adr/0013-volume-based-storage.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# ADR-0013: Volume-Based Storage for Static File Serving
## Status
Accepted
Accepted (updated by ADR-0014)
## Context
The original design (ADR-0003) used Garage (S3-compatible storage) as the primary storage layer, with Caddy reverse-proxying to Garage's S3 API. This introduced complexity:

Expand All @@ -14,13 +14,15 @@ For a self-hosted deployment platform serving static sites, this adds unnecessar

## Decision
- **Storage**: Use a shared Docker named volume (`shipyard_sites`) mounted in both the worker and Caddy containers.
- **Upload**: After build, worker copies output to `/var/lib/shipyard/sites/{appId}/`.
- **Serving**: Caddy uses `file_server` handler pointing to the app's directory - no proxy, no auth.
- **Retention**: Not implemented for volume storage. Each deploy overwrites the previous (latest deployment always live).
- **Upload**: After build, worker copies output to `/var/lib/shipyard/sites/{appId}/{deploymentId}/`.
- **Activation**: A symlink at `sites/{appId}/current` points to the active deployment's directory. Swap the symlink on rollback or re-deploy.
- **Serving**: Caddy's `file_server` root points to `sites/{appId}/current` — set once at first deploy, unchanged on rollback.
- **Retention**: Per-deployment subdirectories are pruned after each successful deploy (keep newest `KEEP_COUNT`, default 5). Building deployments excluded.

## Architecture
```
Worker builds → copies to shared volume → Caddy serves via file_server
Worker builds → extracts to sites/{appId}/{deploymentId}/
→ ln -sfn {deploymentId} current → Caddy serves via sites/{appId}/current
```

### Docker Compose
Expand All @@ -38,14 +40,23 @@ services:
- shipyard_sites:/var/lib/shipyard/sites:ro
```

### Symlink layout
```
sites/{appId}/
├── current/ → symlink to active deployment
├── {deploymentIdX}/ → files from deployment X
├── {deploymentIdY}/ → files from deployment Y (pruned after KEEP_COUNT)
└── ...
```

### Caddy Route (per-app)
```json
{
"@id": "app-{appId}",
"match": [{ "host": ["{domain}"] }],
"handle": [{
"handler": "file_server",
"root": "/var/lib/shipyard/sites/{appId}"
"root": "/var/lib/shipyard/sites/{appId}/current"
}],
"terminal": true
}
Expand All @@ -58,12 +69,13 @@ For SPAs: use subroute with error fallback to rewrite to `/index.html`.
- Zero auth complexity - Caddy reads directly from filesystem
- Simple deployment - no Garage cluster to manage
- Fast serving - filesystem I/O vs network round-trip to S3
- No retention needed - latest deployment always serves
- Symlink activation — atomic, no Caddy API call on rollback per ADR-0014
- Per-deployment artifact isolation — immutable historical builds

- **Cons:**
- No built-in redundancy - volume is server-local
- No content hashing/deduplication (schema exists but unused)
- Rollback loses previous deployment's files (only DB pointer changes)
- Per-deployment retention requires pruning (background job per deploy)

## Alternatives Considered
- **Garage with Web API (port 3902)**: Requires `root_domain` config, bucket website flag, host header manipulation in Caddy - fragile.
Expand Down
73 changes: 73 additions & 0 deletions docs/adr/0014-symlink-activation-model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# ADR-0014: Symlink-Based Activation for Static Sites

Rollback needs per-deployment artifacts and atomic activation. Use per-deployment subdirectories (`sites/{appId}/{deploymentId}/`) and a symlink (`sites/{appId}/current`) pointing to the active deployment. Caddy root is permanently `sites/{appId}/current` — never changes on rollback or re-deploy.

## Status

Accepted

## Context

ADR-0013 used a flat `sites/{appId}/` directory that every deploy overwrote. Caddy's `root` pointed directly at it. Rollback required changing `apps.activeDeploymentId` in the DB plus a Caddy API call to update the root path — adding latency, a config-generation dependency, and a race window between two deploys finishing concurrently.

Issue #24 identified that files on disk were never reverted on rollback — only the DB pointer changed.

## Decision

### Storage layout

```
sites/{appId}/
├── current/ → symlink to the active deployment
├── {deploymentId}/ → files from deployment X
├── {deploymentId}/ → files from deployment Y (pruned after KEEP_COUNT)
└── ...
```

### Activation

Deploy flow:
1. Build completes → artifacts extracted to `sites/{appId}/{deploymentId}/`
2. `ln -sfn {deploymentId} current` in `sites/{appId}/`
3. `UPDATE apps SET activeDeploymentId = $id`

Rollback flow:
1. Validate `isRollbackable`: `status === 'success' AND prunedAt IS NULL`
2. `ln -sfn {targetDeploymentId} current`
3. `UPDATE apps SET activeDeploymentId = $targetId`

### Caddy

Root permanently points to `sites/{appId}/current` — configured once at first deploy, never touched again for static sites. Caddy is uninvolved in rollback.

### First-deploy null state

Before the first successful deploy, `sites/{appId}/current` does not exist. Caddy returns 404 for the app's domain. No placeholder files or fallback routes.

### Retention

After each successful deploy, prune the app's deployment subdirectories keeping the newest `KEEP_COUNT` (configurable, default 5). Building deployments are excluded from pruning. Pruned directories are deleted, `deployments.prunedAt` is set, and per-deployment Caddy routes (see #43) are removed.

## Consequences

- **Atomic activation** — `ln -sfn` is atomic on Linux. No partial state exposed.
- **Rollback is a filesystem operation** — no Caddy API call, no config race, no latency from infra orchestration.
- **Deploy and activation are now distinct** — a deployment can succeed (artifacts exist) without being active. Activation failure is a system-level error (disk full, permissions) that throws.
- **No Caddy interaction on rollback** — eliminates the race window between concurrent deploys updating Caddy config.
- **Symlink is the single source of runtime truth** — mirrors `apps.activeDeploymentId` at the filesystem level.
- **Prune becomes the authority for artifact invalidation** — `prunedAt` column makes rollback eligibility queryable without filesystem probing.

## Considered Options

1. **Per-deployment subdirectories + Caddy root pointing to active deployment** — rollback requires Caddy API call. Slower, adds race window, couples activation to proxy orchestration. Rejected for #24.

2. **Symlink approach (selected)** — described above. Atomic, Caddy-uninvolved rollback, simple.

3. **Re-build on rollback** — re-clone the commit and rebuild. Guaranteed artifacts but slow. Useful for container rollback (Dockerfile/nixpacks server) where old images may be pruned, but unnecessary for static file rollback.

## Related

- Supersedes the rollback assumptions in ADR-0013 (consequence: "Rollback loses previous deployment's files")
- Updates ADR-0006 (rollback no longer triggers Caddy config)
- Enables #43 (multi-version routing via per-deployment routes)
- Enables #42 (preview deployments via per-branch subdirectories)
1 change: 1 addition & 0 deletions drizzle/0008_nosy_wrecking_crew.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "deployments" ADD COLUMN "pruned_at" timestamp;
Loading
Loading