- TypeScript 48.3%
- Astro 31.5%
- CSS 20.1%
- JavaScript 0.1%
| .forgejo/workflows | ||
| apps | ||
| packages | ||
| tests | ||
| .editorconfig | ||
| .gitignore | ||
| AGENTS.md | ||
| biome.json | ||
| bun.lock | ||
| bunfig.toml | ||
| package.json | ||
| README.md | ||
| spec.md | ||
| tsconfig.json | ||
| tsconfig.tests.json | ||
lutvzn.com and lct.st
Bun workspace for two independent Astro applications deployed as Cloudflare Workers:
lutvzn.com: professional site, blog, and protected contact endpointlct.st: short links, Cloudflare Access-protected administration, and self-hosted service status
This repository implements all 16 phases of spec.md. The lutvzn.com site includes its
local Markdown blog and protected contact form. Most public pages and all blog articles are
prerendered, including /contact; only POST /api/contact runs in the Worker for that flow. The
independent lct.st
application includes D1-backed redirects, Cloudflare Access authentication, and its protected
short-link manager, protected monitored-service configuration, five-minute scheduled checks, and
public status experience.
Architecture
Each app owns its Astro and Wrangler configuration and deploys to a separate Worker. Astro is configured for server output because both apps have request handlers, while routes that do not need runtime work opt into prerendering so Cloudflare Static Assets can serve them directly.
Shared packages contain only implementations used by both applications:
.
├── apps/
│ ├── lutvzn/ # Worker for lutvzn.com
│ └── lct/ # Worker for lct.st
├── packages/
│ ├── ui/ # Future genuinely shared UI primitives
│ └── utils/ # Shared HTTP limits, origin checks, and security headers
├── .forgejo/workflows/ # Forgejo validation and concurrent production deployment
├── tests/ # Focused Bun unit and security-regression tests
├── biome.json # Workspace formatting and linting policy
├── bunfig.toml # Bun test discovery and timeout configuration
├── package.json # Bun workspaces and repository scripts
├── tsconfig.json # Strict shared TypeScript defaults
└── README.md
No monorepo orchestrator or client-side framework is used.
Prerequisites
- Git and access to this private repository
- Bun 1.3.12, matching the pinned
packageManagerversion - A Cloudflare account for remote resources and deployment
- A Cloudflare Zero Trust organization and identity provider for the
lct.stadministrator - Active
lutvzn.comandlct.stzones in that account before attaching custom domains - A NameCrane CraneMail mailbox or app password for
contact@lutvzn.com
Cloudflare access is not required to install, typecheck, test, build, or use isolated local D1.
Fresh clone and validation
Clone the repository, enter its root, and install exactly what is recorded in bun.lock:
git clone <private-repository-url> web
cd web
bun install --frozen-lockfile
Run the complete local validation sequence from the repository root:
bun lint
bun typecheck
bun test
bun run build
bunx wrangler deploy --dry-run --config apps/lutvzn/dist/server/wrangler.json
bunx wrangler deploy --dry-run --config apps/lct/dist/server/wrangler.json
For ordinary dependency installation after the first clone, bun install is available; use the
frozen form in CI and whenever reproducibility matters. bun typecheck regenerates ignored
Cloudflare binding declarations in parallel, then checks packages, apps, and tests in parallel.
Formatting and linting use Biome, including its experimental full Astro support:
bun lint # check formatting, imports, and lint rules
bun format # apply formatting, import organization, and safe fixes
Intentional control-character validation expressions and narrowly scoped CSS overrides are
documented exceptions to the recommended rules in biome.json.
Continuous integration
.forgejo/workflows/ci.yml is the repository's only CI workflow. It runs for pull requests and pushes
to master, installs the pinned Bun version with the frozen lockfile, runs Biome, typechecks, executes
the focused test suite, builds both applications, and validates both generated Worker bundles with
Wrangler dry runs. Pull requests stop after validation and never receive Cloudflare credentials.
After a successful master validation, one production job rebuilds both applications and launches
the two independent Wrangler deployment processes concurrently. The job waits for both processes, so
one failure does not stop the other deployment from finishing, and fails if either process fails.
Running both commands inside one job guarantees that they overlap even when the Forgejo runner can
execute only one job at a time. The lutvzn.com runtime secrets are passed through a
permission-restricted temporary file and removed from the process environment before either Wrangler
process starts.
Forgejo runner Docker networking
The workflow requires a runner label matching ubuntu-latest, configured with a Linux Docker image
that contains Node.js 24 or later. The workflow explicitly uses the node:24-bookworm job image so
Wrangler and its dependencies do not depend on the runner's older default image. The runner creates
an isolated Docker network before the first
step. If a run fails before checkout with could not find free subnet from subnet pools, the
workflow has not executed yet: Docker on the runner has exhausted its automatically allocated
bridge-network subnets, commonly because old job networks remain or the daemon's pools are too
small.
On the runner host, inspect networks first and remove only unused networks after confirming that no other workloads depend on them:
docker network ls
docker network prune
For a durable fix, configure Docker's /etc/docker/daemon.json with one or more unused private
address pools. Choose ranges that do not overlap the host LAN, VPNs, Kubernetes, or existing
container networks. Smaller allocations such as /24 provide many more job networks than the
default /16 allocations. For example, on a dedicated runner where these ranges are unused:
{
"default-address-pools": [
{ "base": "172.30.0.0/16", "size": 24 },
{ "base": "172.31.0.0/16", "size": 24 }
]
}
Restart Docker and then the Forgejo Runner after changing this file:
sudo systemctl restart docker
sudo systemctl restart forgejo-runner
Changing the Forgejo Runner's container.network setting to bridge is a short-term workaround
that avoids allocating a new network per job, but it reduces job isolation and should only be used
on a trusted, dedicated runner. Do not add a workflow step for this error: the runner must create
the network before any workflow step can run.
Persistent Bun cache with rootless Podman
The Forgejo workflow mounts a runner-local Bun package cache into each node:24-bookworm job. This
avoids transferring a compressed actions/cache archive on every run. The following assumes the
rootless runner uses UID 5997 and a Quadlet service that mounts /data and the rootless Podman
socket; replace the path or UID if the runner differs.
Create the host directory and grant the rootless Podman user access:
install -d -m 0770 /home/lutvzn/.local/share/containers/forgejo-runner/bun-cache
chown 5997:5997 /home/lutvzn/.local/share/containers/forgejo-runner/bun-cache
Add this volume to the Quadlet container unit:
Volume=/home/lutvzn/.local/share/containers/forgejo-runner/bun-cache:/cache/bun:z
In /data/runner-config.yml, allow only that host volume under the existing container: section:
container:
valid_volumes:
- /home/lutvzn/.local/share/containers/forgejo-runner/bun-cache
Reload the Quadlet service using the same systemd scope that currently runs it:
systemctl --user daemon-reload
systemctl --user restart forgejo-runner
The workflow's job containers mount the source path at /root/.bun/install/cache. This cache is
shared and writable by workflow jobs, so it must contain dependency packages only—not secrets or
other trusted runner data.
Tests
The focused Bun suite lives in tests/ and runs with Bun's built-in test runner:
bun test
bun run test:watch
The suite covers slug and reserved-route validation, destination URLs, Cloudflare Access JWT
signature and claim validation, contact validation, status calculations, Turnstile response
context, origin checks, bounded form bodies, and generated security headers.
Test files have their own strict tsconfig.tests.json, which is included in the root
bun typecheck command. No Workers-specific test utility is currently needed because these
boundaries are exercised through plain modules and standard Web APIs; add one only when a future
test requires the Workers runtime itself.
Local development
Astro 7's Cloudflare adapter uses the local Workers runtime during development, so bindings from
each app's wrangler.jsonc are available with production-like behavior.
For lutvzn, copy the public and runtime examples before testing the contact form:
cp apps/lutvzn/.env.example apps/lutvzn/.env
cp apps/lutvzn/.dev.vars.example apps/lutvzn/.dev.vars
For lct, apply both committed migrations to isolated local D1:
bun run db:migrate:lct:local
PowerShell users can replace cp with Copy-Item. The local Wrangler configuration supplies an
explicit development identity only for loopback requests, so Cloudflare Access is not required for
local development.
Start either app from the repository root:
bun dev:lutvzn
bun dev:lct
The commands run separately and both default to port 4321. Pass Astro options after -- when a
different host or port is needed, for example:
bun dev:lct -- --port 4322
After a build, run a production-runtime preview with:
bun preview:lutvzn
bun preview:lct
Blog content
Posts live in apps/lutvzn/src/content/blog as Markdown. Frontmatter is validated by
apps/lutvzn/src/content.config.ts:
---
title: A concise article title
description: A useful description for listings, search, and feeds.
publishedAt: 2026-08-15
updatedAt: 2026-08-16 # optional; cannot precede publishedAt
draft: false
tags:
- Product
- Engineering
---
The filename becomes the URL identifier, so clear-systems.md is published at
/blog/clear-systems/. Drafts appear during local development with a draft badge and noindex, but
are excluded from production article routes, listings, RSS, and sitemap output.
Generated discovery endpoints:
/rss.xml/sitemap-index.xml/robots.txt
Contact form
POST /api/contact accepts name, email, subject, and message form fields. The endpoint
validates and bounds every field on the server, checks a decoy field, applies a four-attempt-per-minute
Cloudflare rate limit keyed by a hash of the connecting IP, verifies Turnstile,
and then hands a plain-text message to a small email-provider interface. The configured site address
is always the sender; the visitor address is used only as Reply-To.
The contact page and its success/error result variants are prerendered. Non-JavaScript submissions
redirect to one of those static result pages, while enhanced submissions display the same response
inline. Static-asset routing sends only /api/contact through the Worker and serves the generated
404 page directly for all other unmatched lutvzn.com paths.
The default provider sends through the existing NameCrane CraneMail mailbox over authenticated SMTP
with implicit TLS. The Worker accepts only ports 465 and 2465; use the exact hostname and port
shown in CraneMail's Connection Guide. Resend remains available as a dependency-free HTTP
fallback by setting EMAIL_PROVIDER to resend in apps/lutvzn/wrangler.jsonc and configuring
RESEND_API_KEY.
For local contact-form development, copy both examples and restart the dev server after changing either file:
Copy-Item apps/lutvzn/.env.example apps/lutvzn/.env
Copy-Item apps/lutvzn/.dev.vars.example apps/lutvzn/.dev.vars
bun dev:lutvzn
The examples use Cloudflare's published always-pass Turnstile test keys. Never deploy those keys. The SMTP example hostname is intentionally invalid. Replace it and the example credentials only when deliberately testing real NameCrane delivery; local SMTP tests send real messages.
For production:
- In the Cloudflare dashboard, create a managed Turnstile widget, allow the exact
lutvzn.comhostname, and retain its site key and secret key. Make the public site key available to the Astro build asPUBLIC_TURNSTILE_SITE_KEY; the server-side key becomesTURNSTILE_SECRET_KEY. - In NameCrane, open CraneMail's Connection Guide and copy the implicit-TLS SMTP hostname and
port. Use
465when offered;2465is also supported by this Worker. Keep NameCrane's existing MX, SPF, DKIM, and DMARC records in place. - Set
SMTP_USERNAMEandCONTACT_FROM_EMAILtocontact@lutvzn.com. SetCONTACT_DESTINATION_EMAILto the inbox that should receive submissions; it may also becontact@lutvzn.com. Use a dedicated app password asSMTP_PASSWORDwhen mailbox 2FA is enabled. - Store the runtime values as Worker secrets from
apps/lutvzn:
bunx wrangler secret put TURNSTILE_SECRET_KEY
bunx wrangler secret put CONTACT_FROM_EMAIL
bunx wrangler secret put CONTACT_DESTINATION_EMAIL
bunx wrangler secret put SMTP_HOST
bunx wrangler secret put SMTP_USERNAME
bunx wrangler secret put SMTP_PASSWORD
Build and deploy with the public site key in the build environment:
$env:PUBLIC_TURNSTILE_SITE_KEY = 'your-production-site-key'
bun deploy:lutvzn
For the Resend fallback, verify the sender domain with Resend, change EMAIL_PROVIDER to resend,
and additionally run bunx wrangler secret put RESEND_API_KEY. The public Turnstile site key is not
a secret; TURNSTILE_SECRET_KEY, SMTP credentials, provider credentials, and contact addresses must
never enter client-side code. The rate-limit namespace ID in wrangler.jsonc must remain a unique
positive integer within the Cloudflare account if other rate-limit bindings are added.
Cloudflare's Turnstile widget documentation distinguishes the public site key from the private verification key. Its hostname management documentation describes the hostname allowlist. NameCrane's CraneMail quickstart locates the per-account Connection Guide; Cloudflare documents the Worker's outbound TLS socket API.
lct.st application shell
lct.st uses a compact, dark, utility-oriented interface that is intentionally independent from
the lutvzn.com design system. Its current routes are:
/— prerendered public landing page/status— dynamic, cached public service state and recent history/admin— authenticated, dynamic,noindexadministrative overview/admin/links— authenticated short-link manager/admin/services— authenticated monitored-service manager/.well-known/matrix/client— prerendered Matrix client discovery document forhttps://matrix.lct.st/.well-known/matrix/server— prerendered Matrix federation discovery document formatrix.lct.st:443/:slug— runtime D1 lookup for enabled short links/404.html— general not-found page for routes outside the single-segment slug boundary
The admin routes support managing both short links and monitored-service definitions. Enabled
services are checked every five minutes and safely summarized on the public status route. Reserved
application routes take precedence over the /:slug resolver.
Short-link data and behavior
The initial migration is apps/lct/migrations/0001_create_links.sql. It creates the links table
with timestamps, an enabled flag, case-insensitive uniqueness, application-aligned constraints, and
indexes for public lookup and administration. The repository isolates prepared D1 statements; the
service layer owns normalization, duplicate detection, and validation shared by public resolution
and protected admin actions.
Slugs are normalized to lowercase, may contain letters, numbers, and single internal hyphens, and
are limited to 64 characters. .well-known, admin, api, status, and 404 are reserved.
Destinations are normalized with the URL API, limited to 2,048 characters, restricted to http: and
https:, and cannot contain credentials. The resolver validates stored destinations again before
redirecting.
Enabled links return a temporary 302 response with Cache-Control: private, no-store. This is
deliberate: destinations and enabled state are editable, so clients and intermediary caches must not
pin an old target as they could with a permanent redirect. Disabled, missing, malformed, and reserved
slugs return 404; binding and query failures return 503. Click analytics are intentionally not
recorded, but lookup remains isolated behind the repository/service boundary so analytics can be
added later without changing routing rules.
Local D1 setup
The committed apps/lct/wrangler.local.jsonc is local-only and supplies a DB binding without
pretending to be a production database. Wrangler persists its ignored local state in .wrangler.
From the repository root:
bun run db:migrate:lct:local
bun dev:lct
Open http://localhost:4321/admin, choose Links, and create the first link. The manager applies
the same slug and destination rules as the public resolver. Visit the displayed public URL to test
it; disabled links return 404.
Production D1 setup
The committed apps/lct/wrangler.jsonc retains the existing production database UUID so links,
services, and health history survive the internal rename. Do not create a replacement database or
change that UUID during migration. Authenticate and apply pending migrations from apps/lct:
bunx wrangler login
bunx wrangler d1 migrations apply lct --remote
Keep binding as DB, database_name as lct, and migrations_dir as migrations. The database
UUID is a resource identifier rather than a credential and belongs in Wrangler configuration. For a
separate fresh installation with no existing database, run bunx wrangler d1 create lct, replace the
committed UUID with the returned UUID, and then apply the migrations. Once deployed with Cloudflare
Access configured, create and manage production links at
https://lct.st/admin/links; direct SQL inserts are unnecessary.
Before and after applying migrations, inspect their remote state from apps/lct with:
bunx wrangler d1 migrations list lct --remote
Administrator authentication
Cloudflare Access protects /admin, /admin/*, /api/admin, and /api/admin/*. Public short-link
redirects and /status remain unauthenticated. Create one Self-hosted Access application and add
all four path entries because a trailing wildcard does not cover its parent path. Access applications
deny by default; attach an Allow policy scoped to the administrator's exact IdP email (or a verified
IdP group), and require MFA in the IdP or Access policy. Do not configure a Bypass policy for these
paths.
The Worker also validates Cf-Access-Jwt-Assertion using Cloudflare's rotating remote JWKS. It
requires an RS256 signature, the exact team-domain issuer, the application's audience, expiry,
subject, and email claims. This second check keeps the workers.dev hostname and accidental Access
policy gaps fail-closed. Copy the Application Audience (AUD) Tag from the Access application's
additional settings and set both account-specific values from apps/lct:
bunx wrangler secret put ACCESS_TEAM_DOMAIN
# Enter: https://<your-team-name>.cloudflareaccess.com
bunx wrangler secret put ACCESS_AUD
# Enter the application AUD tag.
These values are identifiers rather than credentials, but storing them as Worker secrets avoids
committing account-specific deployment metadata. The Access application session duration controls
reauthentication. The admin overview links to /cdn-cgi/access/logout, which clears the Access
authorization session. Administrator mutations additionally require same-origin Origin or
Referer evidence and same-origin Fetch Metadata.
Local requests to localhost, 127.0.0.1, or [::1] use ACCESS_DEV_EMAIL from
wrangler.local.jsonc. The bypass is rejected for every other hostname and is not present in the
production Wrangler configuration.
Uptime data and service management
Migration apps/lct/migrations/0002_create_uptime.sql adds:
servicesfor protected monitor configurationhealth_checksfor raw five-minute resultshealth_hourlyandhealth_dailyfor bounded long-term aggregates
Each service stores a display name, private check URL, optional public URL, enabled state, expected
HTTP status, and a timeout constrained to 1–30 seconds. Only authenticated admin and scheduled-check
code uses the full service record containing check_url. The public status repository uses an
explicit safe projection containing only the display name and validated optional public URL.
Apply the migration locally, start the app, and open /admin/services:
bun run db:migrate:lct:local
bun dev:lct
To create the first monitored service:
- Open
http://localhost:4321/admin/services. - Enter a display name and a complete private
http:orhttps:health-check URL. This URL is stored in D1 but is never selected by the public status query. - Optionally enter the public page visitors may open from
/status. - Set the exact successful HTTP status, normally
200, and a timeout from 1,000 to 30,000 ms. - Leave the service enabled, submit it, and invoke the local scheduled endpoint described below.
- Open
http://localhost:4321/statusand confirm the service state and history. Repeat these steps athttps://lct.st/admin/servicesafter production deployment.
Before deploying this phase, apply the same committed migration to the existing production D1
database from apps/lct:
bunx wrangler d1 migrations apply lct --remote
The retention design is executed by the scheduled handler without extra infrastructure:
- Keep raw
health_checksfor approximately seven days. - Roll older raw rows into UTC hourly buckets in
health_hourly, then delete the aggregated raw rows. - Keep hourly buckets through approximately 90 days.
- Roll older hourly buckets into UTC daily buckets in
health_daily, then delete the aggregated hourly rows. - Retain daily aggregates for long-term history; a future storage policy may set a final horizon if actual usage warrants one.
Retention aggregation runs during the first scheduled five-minute window after 00:00 UTC each day; the remaining monitoring cycles only check services and record their raw results.
The aggregate tables retain counts and latency statistics, allowing uptime percentages and useful latency summaries without preserving every five-minute row indefinitely.
Scheduled monitoring
apps/lct/src/worker.ts is a custom Worker entrypoint that delegates HTTP requests to Astro and
handles the */5 * * * * UTC Cron Trigger. Each invocation queries enabled services and checks them
concurrently. Every service has its own abort timeout, so one slow or failing endpoint cannot stop
the others.
A check succeeds only when the returned status exactly matches expected_status. The raw row stores
the observed HTTP status and latency. Connection failures, timeouts, and unexpected statuses are
stored respectively as network, timeout, and unexpected_status; URLs and exception messages
are not written to logs. D1 write failures are reported only after all enabled services have been
attempted, making the cron invocation visibly fail without sacrificing isolation.
To invoke the scheduled handler locally, start lct and call Cloudflare's local scheduled endpoint
from another PowerShell window:
bun dev:lct
Invoke-WebRequest "http://localhost:4321/cdn-cgi/handler/scheduled?cron=*/5+*+*+*+*&format=json"
Inspect recent local results with:
bun run db:query:lct:local -- --command "SELECT service_id, checked_at, succeeded, http_status, latency_ms, failure_reason FROM health_checks ORDER BY checked_at DESC LIMIT 20;"
The cron trigger is registered or updated when bun deploy:lct deploys the Worker. The production
D1 migration and Cloudflare Access metadata must be configured before opening the admin surface.
Public status
/status dynamically reads at most 100 enabled services, the latest 24 raw checks per service, and
24-hour success counts. Production Workers Caching serves fresh status responses for five minutes
without re-running Astro or D1, allows a short stale-while-revalidate window, and separately permits
browsers to reuse a response for 60 seconds. Database failures return a generic 503 with no
caching. Private admin pages, short-link redirects, and error responses explicitly bypass the
Worker cache.
The public query never selects the private check URL. Stored public URLs are also validated again before rendering. Disabled services are omitted entirely.
Status rules are:
- Operational: the latest check returned the configured expected HTTP status.
- Degraded: the latest check received an HTTP response with a different status.
- Offline: the latest check ended in a network failure or timeout.
- Unknown: no check exists or the latest check is more than 15 minutes old.
Overall state reflects the most severe enabled service in the order offline, degraded, unknown, then operational. The 24-hour uptime percentage is successful raw checks divided by all raw checks in that window. Each service shows up to 24 accessible history segments ordered oldest to newest.
Cloudflare configuration
The core Worker configuration is in:
apps/lutvzn/wrangler.jsoncapps/lct/wrangler.jsonc
Both configs use Astro's Cloudflare integration, a pinned compatibility date, static asset serving
from dist, and Worker observability. lutvzn uses a static 404 fallback with selective
Worker-first routing for /api/contact. The production lct configuration enables Workers
Caching for explicitly public responses and uses a custom entrypoint to combine Astro HTTP handling
with scheduled events. nodejs_compat is enabled for adapter and dependency compatibility;
application code should still prefer Web APIs.
The apps/lct/wrangler.jsonc Worker is named lct and explicitly enables workers_dev. With
the account subdomain lutvzn, its deployment URL is:
https://lct.lutvzn.workers.dev
The public custom domain lct.st remains separate from the Worker name. The logical production D1
database name is lct; its existing UUID is retained in Wrangler to preserve the existing data and
binding. The database name changes without creating a replacement database.
The Cloudflare adapter currently adds an automatically provisioned SESSION KV binding to its
generated deploy configuration. The applications do not rely on it; lct uses Cloudflare Access
and verifies the forwarded Access JWT without storing application sessions.
The production DB binding is configured in wrangler.jsonc; local development uses the isolated
database in wrangler.local.jsonc. Both configurations expose the five-minute scheduled handler;
only a production deployment registers the remote Cron Trigger.
The root typecheck command generates both config-derived Worker binding types in parallel before checking the independent workspace targets in parallel. You can also regenerate the binding types directly after changing either Wrangler file:
bun cf-types
Generated worker-configuration.d.ts files are local build artifacts and are not committed, so
bun typecheck always regenerates them and works in a fresh clone and in CI.
Binding and configuration inventory
lutvzn uses the following production configuration:
| Name | Kind | Required | Purpose and source |
|---|---|---|---|
ASSETS |
Static Assets binding | Yes | Serves the generated dist assets; declared in wrangler.jsonc. |
CONTACT_RATE_LIMITER |
Rate-limit binding | Yes | Four contact attempts per key per minute; declared with namespace 1001. |
EMAIL_PROVIDER |
Plain Worker variable | Yes | smtp by default; change to resend only with the fallback configured. |
SMTP_PORT |
Plain Worker variable | SMTP only | Implicit-TLS submission port, 465 by default; NameCrane may alternatively specify 2465. |
TURNSTILE_SECRET_KEY |
Worker secret | Yes | Verifies contact-form tokens server-side. |
CONTACT_DESTINATION_EMAIL |
Worker secret | Yes | Inbox receiving contact submissions. |
CONTACT_FROM_EMAIL |
Worker secret | Yes | Authenticated sender, normally contact@lutvzn.com. |
SMTP_HOST |
Worker secret | SMTP only | Exact NameCrane hostname from CraneMail's Connection Guide. |
SMTP_USERNAME |
Worker secret | SMTP only | NameCrane mailbox login, normally contact@lutvzn.com. |
SMTP_PASSWORD |
Worker secret | SMTP only | Dedicated mailbox password or app password. |
RESEND_API_KEY |
Worker secret | Resend only | Authorizes the fallback HTTP provider. |
PUBLIC_TURNSTILE_SITE_KEY |
Public build variable | Yes for the form | Embedded in generated contact-page HTML; set in .env locally or as the Forgejo Actions repository variable for master-branch deployments. |
lct uses:
| Name | Kind | Required | Purpose and source |
|---|---|---|---|
ASSETS |
Static Assets binding | Yes | Serves generated assets; declared in both Wrangler files. |
DB |
D1 binding | Yes | lct-local locally and the real lct database remotely. |
ACCESS_TEAM_DOMAIN |
Worker secret or variable | Production | Exact https://<team>.cloudflareaccess.com JWT issuer and JWKS origin. |
ACCESS_AUD |
Worker secret or variable | Production | Audience tag of the Access application protecting all admin paths. |
ACCESS_DEV_EMAIL |
Local Wrangler variable | Local only | Supplies the loopback-only development identity; never configured in production. |
*/5 * * * * |
Cron Trigger | Yes for monitoring | Invokes the scheduled handler every five minutes in UTC. |
Astro's adapter also adds a SESSION KV binding to generated deployment configuration. Neither app
uses it. Binding names are application contracts: if a Wrangler name changes, update the code and
run bun cf-types before deployment. Rate-limit namespace IDs must be unique within the Cloudflare
account unless two bindings are deliberately meant to share counters.
Security model
Both Workers set the same baseline browser protections on dynamic responses through Astro
middleware and on Cloudflare-served static responses through each app's public/_headers file.
These include framing and MIME-sniffing protection, a restricted referrer policy, a restrictive
permissions policy, and one-year HSTS. Astro generates a resource-specific, hash-based Content
Security Policy for prerendered documents, while middleware hashes scripts in dynamic HTML and
emits the equivalent response header. Neither path requires unsafe-inline. lutvzn allows
Turnstile scripts, frames, and connections only from https://challenges.cloudflare.com; lct
allows only same-origin application resources.
Contact and administrator form bodies are read with an actual streamed byte limit, including
requests without a trustworthy Content-Length. Browser mutations require same-origin Fetch
Metadata plus an Origin or Referer. Production Turnstile responses must carry the contact
action and match the request
hostname, and the server never returns or logs the submitted token. The documented always-pass test
secret accepts only a response Cloudflare marks as produced with a testing key, because those fixed
test responses do not preserve the widget's action or local hostname. Never deploy that test
secret. Cloudflare Access policy enforcement and JWT verification, contact throttling, prepared D1
statements, allowlisted redirects, and safe public status projections provide the remaining abuse
and disclosure boundaries.
Origin validation, body limits, headers, and error behavior are unchanged during local Workers
development. Access itself is replaced by a loopback-only development identity. The local CSP
permits inline styles only on loopback when ACCESS_DEV_EMAIL is present because Vite injects CSS
through development-only <style> elements; deployed hostnames retain the strict production
style-src. HSTS is sent locally too, but browsers only act on it over HTTPS.
Astro's generated hash CSP is intentionally active in builds and preview, but Astro does not
support that feature in its Vite-powered dev mode; use a production build plus the app's preview
command for CSP verification. The committed Turnstile test keys and isolated local D1 binding are
the other intentional local environment differences; never deploy the test keys or .dev.vars
files.
Local secrets
Copy the examples for the application you are working on:
cp apps/lutvzn/.dev.vars.example apps/lutvzn/.dev.vars
cp apps/lct/.dev.vars.example apps/lct/.dev.vars
On PowerShell:
Copy-Item apps/lutvzn/.dev.vars.example apps/lutvzn/.dev.vars
Implemented lutvzn secrets and variables:
TURNSTILE_SECRET_KEYCONTACT_DESTINATION_EMAILCONTACT_FROM_EMAILSMTP_HOSTSMTP_USERNAMESMTP_PASSWORDPUBLIC_TURNSTILE_SITE_KEY(public build-time variable in.envlocally or a Forgejo Actions repository variable, not a secret)EMAIL_PROVIDER(non-secret Wrangler variable; defaults tosmtp)SMTP_PORT(non-secret Wrangler variable; defaults to implicit-TLS port465)RESEND_API_KEYwhen Resend is selected
Implemented lct production metadata:
ACCESS_TEAM_DOMAINACCESS_AUD
Never commit .dev.vars, .env files, API credentials, or private monitored-service URLs. A D1
database UUID is a non-secret resource identifier and is intentionally committed in the production
Wrangler configuration. Store production runtime secrets per Worker with wrangler secret put.
Production deployment
Forgejo pushes to master deploy both Workers automatically after the validation job succeeds. The
workflow starts both Wrangler processes concurrently and waits for both to finish, so a failure in
one deployment does not prevent the other from completing. Pull requests remain validation-only.
Manual deployment commands are still available below.
Run the named scripts with bun run deploy:lutvzn or bun run deploy:lct from the repository
root. bun deploy is reserved by Bun and does not run a package script; choose the Worker you
want to deploy explicitly.
Forgejo deployment setup
Add these to the repository's Settings > Actions > Secrets:
CLOUDFLARE_API_TOKEN— a custom Cloudflare API token with permission to edit Workers, scoped to the account that owns both Workers.CLOUDFLARE_ACCOUNT_ID— the Cloudflare account ID.TURNSTILE_SECRET_KEY— the production Turnstile widget secret.CONTACT_FROM_EMAIL—contact@lutvzn.com.CONTACT_DESTINATION_EMAIL— the inbox that receives contact submissions.SMTP_HOST— the exact implicit-TLS hostname from NameCrane's Connection Guide.SMTP_USERNAME— normallycontact@lutvzn.com.SMTP_PASSWORD— the dedicated NameCrane mailbox or app password.
Add this under Settings > Actions > Variables:
PUBLIC_TURNSTILE_SITE_KEY— the production Turnstile public site key used while buildinglutvzn.com. This is public configuration, not a secret.
The workflow uses the pinned Bun and Wrangler versions from the repository lockfile. The trusted
master deployment writes the six lutvzn runtime values to a permission-restricted temporary JSON
file, unsets those values from the deployment shell, passes the file only to the lutvzn.com
wrangler deploy process, and removes it on exit. Wrangler applies those secrets to the new Worker
version in the same deployment; they are not exposed to checkout, dependency installation,
validation, either build step, or the lct.st Wrangler process. The workflow does not provision
Cloudflare resources. Configure the lct ACCESS_* secrets in Cloudflare as described below.
Protect the master branch and limit who can push to it because a successful push can deploy both
Workers.
1. Authenticate and run the preflight
From the repository root:
bunx wrangler login
bunx wrangler whoami
bun install --frozen-lockfile
bun lint
bun typecheck
bun test
bun run build
bunx wrangler deploy --dry-run --config apps/lutvzn/dist/server/wrangler.json
bunx wrangler deploy --dry-run --config apps/lct/dist/server/wrangler.json
The dry runs package generated Worker output but neither authenticate nor change Cloudflare state.
2. Prepare production resources
Before the first deployment:
- Configure the
lctD1 database name and retain the existing UUID inapps/lct/wrangler.jsonc, then apply all remote migrations as documented above. - When replacing an already deployed Worker, disable its existing five-minute Cron Trigger before
deploying
lct. This prevents both Workers from writing duplicate health checks to the shared D1 database during cutover. - Create the production Turnstile widget and prepare its public and secret keys.
- Copy the NameCrane SMTP server from CraneMail's Connection Guide and prepare the
contact@lutvzn.commailbox or app password, or configure Resend. - Create the Cloudflare Access application and administrator Allow policy described above, then copy its team domain and audience tag.
- Confirm rate-limit namespace ID
1001does not collide with unrelated bindings in the account.
3. Create the Workers and set secrets
Run an initial deployment to create each Worker at its temporary workers.dev address. Do not
attach the public domains until all secrets are present.
PowerShell:
$env:PUBLIC_TURNSTILE_SITE_KEY = 'your-production-site-key'
bun deploy:lutvzn
bun deploy:lct
Push-Location apps/lutvzn
bunx wrangler secret put TURNSTILE_SECRET_KEY
bunx wrangler secret put CONTACT_FROM_EMAIL
bunx wrangler secret put CONTACT_DESTINATION_EMAIL
bunx wrangler secret put SMTP_HOST
bunx wrangler secret put SMTP_USERNAME
bunx wrangler secret put SMTP_PASSWORD
# Only when EMAIL_PROVIDER is "resend":
# bunx wrangler secret put RESEND_API_KEY
Pop-Location
Push-Location apps/lct
bunx wrangler secret put ACCESS_TEAM_DOMAIN
bunx wrangler secret put ACCESS_AUD
Pop-Location
Bash:
PUBLIC_TURNSTILE_SITE_KEY='your-production-site-key' bun deploy:lutvzn
bun deploy:lct
cd apps/lutvzn
bunx wrangler secret put TURNSTILE_SECRET_KEY
bunx wrangler secret put CONTACT_FROM_EMAIL
bunx wrangler secret put CONTACT_DESTINATION_EMAIL
bunx wrangler secret put SMTP_HOST
bunx wrangler secret put SMTP_USERNAME
bunx wrangler secret put SMTP_PASSWORD
# Only when EMAIL_PROVIDER is "resend":
# bunx wrangler secret put RESEND_API_KEY
cd ../lct
bunx wrangler secret put ACCESS_TEAM_DOMAIN
bunx wrangler secret put ACCESS_AUD
cd ../..
wrangler secret put creates and immediately deploys a new Worker version. Run the normal deploy
commands again after all values are set so the final version is built from the intended commit:
$env:PUBLIC_TURNSTILE_SITE_KEY = 'your-production-site-key'
bun deploy:lutvzn
bun deploy:lct
Keep PUBLIC_TURNSTILE_SITE_KEY in the deployment environment: it is compiled into the contact
page and is not supplied by wrangler secret put.
4. Attach custom domains and verify Access paths
The repository deliberately leaves routes out of both Wrangler files so cloning it does not
claim hostnames in a particular Cloudflare account. After both Workers are configured:
- In Cloudflare, open Workers & Pages, select the
lutvznWorker, then choose Settings > Domains & Routes > Add > Custom Domain and enterlutvzn.com. - Repeat for the
lctWorker withlct.st. - Resolve any conflicting CNAME before attaching the hostname. Cloudflare creates the Worker DNS record and certificate for a Custom Domain.
- Attach only the apex names unless a separate
wwwpolicy is configured. Canonical URLs in this repository usehttps://lutvzn.comandhttps://lct.st; awwwhostname should redirect to its apex with a Cloudflare Redirect Rule rather than serving duplicate content.
Renaming the existing Worker creates a new Worker deployment. Set ACCESS_TEAM_DOMAIN and
ACCESS_AUD on lct, move the lct.st custom domain to it, and verify that the new Worker reads the
same D1 data. After cutover, remove the former Worker whose Cron Trigger was disabled before
deployment. The configured D1 name changes to lct, while retaining the existing UUID preserves the
binding and data; the Cloudflare dashboard resource may retain its previous display name.
5. Confirm the self-hosted Access application covers the four exact admin path entries documented
above. Test with the authorized administrator and an unauthorized identity before using the UI.
For an existing lct.cx deployment, complete these migration steps before removing the old
hostname:
- Edit the existing Access application so its four protected paths use
lct.st. Editing the application normally preserves its audience; if a replacement application is created instead, copy its new AUD tag into the Worker'sACCESS_AUDsecret and redeploy. - Create or migrate the DNS, proxy, and origin configuration for
chat.lct.st,git.lct.st, andmatrix.lct.st. Update the corresponding Element/Matrix and Forgejo public-hostname, trusted-host, and TLS settings before the landing page sends users to them. - If
lct.cxremains under your control, keep it attached long enough to redirect requests to the same path and query onlct.st, preserving existing short links. If the old domain is no longer controlled, those links cannot be redirected and should be treated as retired. - Review existing D1 service records in
/admin/services. Update any private check URLs or public URLs that still point at the old service hostnames after the new origins are ready.
Custom Domains are appropriate because each Worker is the origin for every path. See Cloudflare's Custom Domains guide for current dashboard and Wrangler procedures.
5. Verify production
After DNS and certificates are active:
- Open
https://lutvzn.com, an article,/rss.xml, and/sitemap-index.xml. - Submit the contact form and confirm delivery, sender, and visitor
Reply-Tobehavior. - Open
https://lct.st/admin, complete the Cloudflare Access login, create the first short link under/admin/links, and confirm its public URL returns a302to the configured destination. - Create the first monitored service under
/admin/services. Wait for the next five-minute UTC trigger and verify it appears at/status. - Request
https://lct.st/.well-known/matrix/clientand confirm it returns the configured Matrix homeserver as JSON withAccess-Control-Allow-Origin: *. Requesthttps://lct.st/.well-known/matrix/serverand confirm it returns{"m.server":"matrix.lct.st:443"}as JSON. - From
apps/lct, inspect recent remote checks without exposing private URLs:
bunx wrangler d1 execute lct --remote --command "SELECT service_id, checked_at, succeeded, http_status, latency_ms, failure_reason FROM health_checks ORDER BY checked_at DESC LIMIT 20;"
Use bunx wrangler tail lutvzn or bunx wrangler tail lct from the corresponding app directory
while diagnosing production failures. Observability is enabled in both Wrangler configurations;
application logs intentionally omit submitted Turnstile tokens and private check URLs.
Cloudflare free-tier suitability
The following assessment was verified against Cloudflare documentation on 2026-08-16 and must be rechecked before launch because plan limits can change:
- Prerendered pages and assets should dominate
lutvzn.com; Cloudflare documents Static Asset requests as free and unlimited. Only contact submissions—not contact-page views—invoke thelutvznWorker. Admin, redirect, and uncached status requests invokelct; cached status hits avoid Worker execution but remain billable requests under current Workers Caching pricing. - The Workers Free plan currently allows 100,000 requests per day, 10 ms CPU per HTTP/Cron invocation, 50 external subrequests per invocation, and five Cron Triggers per account. This repository uses two Workers and one five-minute trigger (288 scheduled invocations per day).
- D1 Free currently includes 5 million rows read per day, 100,000 rows written per day, and 5 GB total storage. A small personal monitor creates roughly 288 raw check records per enabled service each day, plus retention aggregates; D1 billing metrics can count additional writes for indexes. The indexed, bounded status queries and retention policy are designed for this scale.
- Keep materially fewer than 50 enabled monitored services on the Free plan: each monitoring cycle performs one outbound check per service, and additional redirects made by a target also consume subrequests. Watch Cron CPU time and D1 row metrics as the service count grows.
- Turnstile supports Free-plan use; a free widget currently allows up to ten hostnames. The contact
form needs only
lutvzn.com. - Contact delivery uses the existing NameCrane subscription over implicit-TLS SMTP and does not require Cloudflare Email Routing or Workers Paid. NameCrane's current sending limits and account terms apply. Resend has its own independent pricing and limits when selected.
- Workers Logs is included on Free with a current 200,000-event daily allowance and three-day retention. The five-minute completion log and light personal traffic should remain modest.
Current authoritative references: Workers limits, Static Assets billing, D1 pricing, and Workers Logs. Exceeding a Free-plan limit can reject requests or D1 operations rather than silently preserving service, so use the Cloudflare dashboards and upgrade before sustained traffic approaches a limit.
Deliberate choices and limitations
Deliberate choices:
- Two Workers preserve independent releases, bindings, domains, and failure boundaries.
- Local Markdown avoids a CMS; plain Astro and server-rendered forms avoid a client framework.
- Editable short links use non-cacheable
302redirects rather than permanent redirects. - Cloudflare Access owns administrator identity and session policy; the Worker independently verifies its signed application token instead of maintaining a second session store.
- D1 prepared repositories separate persistence from validation and public response shapes.
- NameCrane SMTP is the default, with a small Resend fallback rather than a provider SDK.
- Custom domains are dashboard-managed while code, bindings, migrations, and Cron configuration remain repository-managed.
Current limitations and optional future work:
- Administrator membership, MFA, recovery, session revocation, and authentication audit logs depend on the configured Cloudflare Access policy and identity provider rather than application code.
- Short links do not collect click analytics, expiration dates, or per-link access controls.
- Monitoring performs one HTTP
GETfrom Cloudflare, validates only the exact status, and has no regional probes, custom request headers, incident records, or outage notifications. - The public UI emphasizes recent raw checks and 24-hour uptime; stored hourly/daily aggregates are retained for future longer-range views. Daily aggregates currently have no final expiration.
- The contact rate-limit binding is intentionally permissive and eventually consistent, so it mitigates abuse but is not a billing or identity control.
- There is no staging Wrangler environment, database backup workflow, or automated end-to-end
Workers-runtime suite. Add these only when operational needs justify them. Automatic production
deployment is currently configured only for pushes to
masterin Forgejo. - Domain attachment and production SMTP delivery require dashboard/account work and cannot be proven by the repository's offline CI dry runs.
Command reference
Run all commands from the repository root unless a different directory is stated:
# Install and validate
bun install --frozen-lockfile
bun lint
bun format
bun typecheck
bun test
bun run test:watch
bun run build
# Develop and preview
bun dev:lutvzn
bun dev:lct
bun preview:lutvzn
bun preview:lct
# Cloudflare-derived types and local D1
bun cf-types
bun run db:migrate:lct:local
bun run db:query:lct:local -- --command "SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name;"
# Independent production deployments
bun run deploy:lutvzn
bun run deploy:lct