Cloudsteading
Garrul admin dashboard with comment statistics, embed snippet and activity chart. Upstream v2.16.0 UI with synthetic demo data; may differ from the reviewed v2.31.0 code. Not a Cloudsteading deployment.
Garrul admin dashboard with comment statistics, embed snippet and activity chart. Upstream v2.16.0 UI with synthetic demo data; may differ from the reviewed v2.31.0 code. Not a Cloudsteading deployment.

Garrul

Self-hosted comments for static sites: a Cloudflare alternative to Disqus, with moderation and migration tools.

Garrul is a self-hosted Comentario/Commento alternative built on Cloudflare (Analytics Engine, Cache API, D1, KV, Turnstile). Free tier eligible within limits. Inspect the source and license in the linked repository.

Source & license

Upstream license: Apache-2.0

License TL;DR

You can use, change and sell it, including in closed-source products. When sharing copies, include the license, keep required notices and mark changed files. It includes a contributor patent grant with conditions, but no trademark permission or warranty.

Explain Apache 2.0 in plain English →

Summary of the main license. Separate packages and assets can have different terms.

Inspect repository ↗Read this project’s actual license ↗

Repository owner

@KingPin

See the upstream repository for the original creator and contributors.

Maintain this project? Maintainer verification →

Cloudflare hosting

Free tier eligible within limits

The baseline Worker, D1, KV and Analytics Engine configuration can fit Cloudflare Free allowances for a modest comment workload. Usage is shared across the account; email, optional AI services and a custom domain can add costs. Eligibility is source-reviewed, not a deployed load test.

Hosting requirements
  • Workers Free permits 100,000 requests per day and 10 ms CPU per invocation. Measure real embed traffic, imports and cron jobs; do not equate site visitors with Worker requests.
  • D1 Free includes 5 million rows read and 100,000 rows written per day with 5 GB total storage. Scan-heavy dashboards, comment history and migrations consume these allowances.
  • KV Free includes 100,000 reads and 1,000 writes per day with 1 GB storage across the account. Sessions and one-time handoff tokens use KV; the default response cache and limiter use the Cache API.
  • Analytics Engine currently lists 100,000 data points and 10,000 read queries per day on Workers Free. Its pricing page says billing is not yet active and may change.
  • Configure production secrets, resource IDs, site origins and at least one OAuth provider that returns an administrator email. Anonymous posting additionally requires a real Turnstile key pair.
  • Optional reply/moderator email needs Resend and a verified sender domain; optional AI/Akismet classification and integrations have their own limits and charges.
  • A workers.dev address is documented, but the source recommends a custom subdomain to reduce third-party-cookie friction. Domain purchase and browser-specific login behavior are outside the free-hosting claim.
Check current pricing ↗
Review findings & limitations
  • Source and architecture review at immutable commit 1a18813af9e822bf53a7b81c2fd681e2d9bf8aac; no upstream installation, CI run, comment migration, OAuth login or notification delivery was performed by Cloudsteading.
  • The public deployment target is the documented wrangler.example.toml template. Actual private wrangler.toml resource IDs and operator configuration are not in the repository.
  • 142 runtime files and all four discovered GitHub workflows were ingested. Release CI builds and uploads assets; it does not deploy an operator Worker.
  • Default configuration declares four KV namespaces. RATE_LIMITS is retained for optional classifier caching, TREE_CACHE is legacy, and the response cache/default limiter run on Cache API rather than KV.
  • Default rate limiting is per-colo and non-atomic, with fail-open behavior on storage failures. Optional Durable Objects rate limiting and Workers AI classification are not part of the baseline architecture.
  • The author-provided application screenshots use synthetic fixture data and reflect v2.16.0. The reviewed package is v2.31.0; screenshots do not establish the current UI or a Cloudsteading-operated deployment.
  • The README calls other integrations optional, but a usable moderation admin needs an OAuth email identity in ADMIN_EMAILS. Production startup requires IP_HASH_SECRET and JWT_SECRET; anonymous posts require Turnstile.
  • Cusdis is deprecated and archived upstream as of the 2026-10-02 check. Its relation is explicitly a legacy migration path.
  • Apache-2.0 source and upstream NOTICE are retained with the cached screenshots. The separate license guide summarizes terms; the linked upstream text governs.
Sources checked 01/10/2026

Repository snapshot: 1a18813. Hosting eligibility reflects the deployment documentation and listed assumptions.

  • disqus ↗

    Garrul reads five comment systems today: Disqus, Remark42, Comentario (and its predecessor Commento), isso, and Cusdis.

  • remark42 ↗

    Garrul reads five comment systems today: Disqus, Remark42, Comentario (and its predecessor Commento), isso, and Cusdis.

  • comentario ↗

    Garrul reads five comment systems today: Disqus, Remark42, Comentario (and its predecessor Commento), isso, and Cusdis.

  • commento ↗

    Garrul reads five comment systems today: Disqus, Remark42, Comentario (and its predecessor Commento), isso, and Cusdis.

  • isso ↗

    Garrul reads five comment systems today: Disqus, Remark42, Comentario (and its predecessor Commento), isso, and Cusdis.

  • cusdis ↗

    Garrul reads five comment systems today: Disqus, Remark42, Comentario (and its predecessor Commento), isso, and Cusdis.

  • workers ↗

    name = "garrul" main = "src/index.ts"

  • d1 ↗

    [[d1_databases]] binding = "DB" database_name = "garrul-db"

  • kv ↗

    [[kv_namespaces]] binding = "SESSIONS" id = "PASTE_FROM_WRANGLER_KV_CREATE"

  • analytics-engine ↗

    [[analytics_engine_datasets]] binding = "ANALYTICS" dataset = "garrul_events"

  • turnstile ↗

    const ENDPOINT = "https://challenges.cloudflare.com/turnstile/v0/siteverify";

  • cache-api ↗

    Expensive-to-rebuild GET responses — the first page of a comment tree, the * multi-slug counts roll-up — are cached at the edge via `caches.default`

  • free-tier-eligible ↗

    | **Free** | 100,000 per day | No charge for duration | 10 milliseconds of CPU time per invocation |

  • free-tier-eligible ↗

    | Rows read | 5 million / day | First 25 billion / month included + $0.001 / million rows |

  • free-tier-eligible ↗

    | Keys written | 1,000 / day | 1 million/month, + $5.00/million |

  • free-tier-eligible ↗

    | **Workers Free** | 100,000 included per day | 10,000 included per day |

  • Apache-2.0 ↗

    Apache License Version 2.0, January 2004 http://www.apache.org/licenses/

  • architecture ↗

    name = "garrul" main = "src/index.ts"

  • architecture ↗

    crons = ["*/15 * * * *"]

  • architecture ↗

    **Admin UI**: `/admin`, for an OAuth sign-in whose email is in `ADMIN_EMAILS`.

  • architecture ↗

    **One-command install** — `npm run setup` creates resources, sets secrets and vars, migrates, deploys and checks health

Upstream screenshot · KingPin/Garrul repository contributors ↗. Depicts the upstream project. We have not deployed and tested a fresh installation here.

What it can replace

Compare the workflow you need. These mappings describe overlap; full feature parity requires a separate comparison.

Disqus logoDisqus ↗

Embeddable threaded comments and moderation for a site you own, with a documented Disqus XML/XML.GZ importer. Does not reproduce the Disqus network, monetization or hosted operations.

See supporting source ↗
Remark42 logoRemark42 ↗

Self-hosted blog comments with a documented Remark42 JSON/GZ importer. Review the migration field rules; votes, scores, pins and administrator roles are not a promise of parity.

See supporting source ↗
Comentario logoComentario ↗

Embeddable self-hosted comments and moderation, with a documented Comentario JSON import. OAuth identities, integrations and all provider features are not automatically portable.

See supporting source ↗
Commento logoCommento ↗

A Cloudflare-hosted comment widget and moderation workflow, with a reader for legacy Commento exports. Validate author identity and exported fields before replacing an existing installation.

See supporting source ↗
Isso logoIsso ↗

Self-hosted comments on a static site, with a documented two-step SQLite backup to JSON migration. Importing comments does not migrate sessions, credentials or the complete Isso feature set.

See supporting source ↗
Cusdis logoCusdis ↗

A migration destination for an existing Cusdis installation or SQLite backup. Cusdis is deprecated and its upstream repository is archived; this comparison is a legacy migration path, not a claim that its hosted service is currently available.

See supporting source ↗
Garrul reader widget with a Markdown composer, reactions and threaded comments. Upstream v2.16.0 UI with synthetic demo data; may differ from the reviewed v2.31.0 code. Not a Cloudsteading deployment.Garrul moderation queue with pending comments and approval actions. Upstream v2.16.0 UI with synthetic demo data; may differ from the reviewed v2.31.0 code. Not a Cloudsteading deployment.
external SaaS target
varies
→ Analytics Engine + Cache API + D1
external SaaS target
varies
→ Analytics Engine + Cache API + D1
external SaaS target
varies
→ Analytics Engine + Cache API + D1
external SaaS target
varies
→ Analytics Engine + Cache API + D1
external SaaS target
varies
→ Analytics Engine + Cache API + D1
external SaaS target
varies
→ Analytics Engine + Cache API + D1

How it works

The shape of Garrul on Cloudflare, and how it stacks up against the rented tools it replaces.

Diagram target: wrangler.example.toml. Other repository deployments are listed in the source evidence below; they are not required by this target.

Architecture

Diagram of deployment declarations at the reviewed commit. Each app has its own entrypoint; declared resources do not prove runtime calls. Follow file and line sources below.

View upstream source ↗
Public interface
Configured entry points1
garrul
wrangler.example.toml
↓
App
garrul
entry
Cloudflare Workers
Entrypoint: src/index.tsConfigured cron (UTC): */15 * * * *
↓

Configuration and workflow sources

Reviewed commit 1a18813af9e8. This configuration evidence comes from reading source files. Execution checks, when available, appear in the project's runtime review.

Deployment configuration · 1 file
wrangler.example.toml ↗

Cloudflare Workers · documented installation template; operator setup required · compatibility 2025-11-01

Selected from the upstream installation instructions ↗. This is a template to configure in your account; no fresh deployment is established.

garrul · default

Entrypoint: src/index.ts

Cron triggers (UTC): */15 * * * *

  • DB → D1
  • RATE_LIMITS → KV
  • OAUTH_STATE → KV
  • SESSIONS → KV
  • TREE_CACHE → KV
  • ANALYTICS → Analytics Engine

Named environments are separate deployments. Bindings are shown only where declared. Configured routes are URL patterns, not verified application endpoints.

Runtime source · handlers, binding usage and workflow steps

Observed TypeScript/JavaScript declarations from Worker entrypoints and resolved relative imports. Calls and workflow steps may run conditionally; their listed order is not a proven end-to-end request flow. Router declarations may be mounted under a prefix or may not be registered. This shows code wiring, not a successful deployment or runtime test. Dynamic wiring, aliases and generated code may not resolve.

src/index.ts ↗
  • L376 · fetch handler exported
  • L377 · scheduled handler exported · calls ctx.waitUntil, catch, runDigest, log.error, String, runModeratorDigest, runWebhookRetries, runTelegramDigest, runIpRetention, runAuditRetention
  • L238 · app.use("*")
  • L265 · app.use("*")
  • L280 · app.use("*")
  • L301 · app.use("*")
  • L319 · app.use("*")
  • L321 · app.use("/api/*")
  • L322 · app.use("/api/*")
  • L327 · app.use("/api/*")
  • L329 · app.route("/api/v1/health")
  • L333 · app.route("/api/v1/bootstrap")
  • L334 · app.route("/api/v1/comments")
  • L335 · app.route("/api/v1/comments")
  • L336 · app.route("/api/v1/reactions")
  • L337 · app.route("/api/v1/page-engagement")
  • L338 · app.route("/api/v1/votes")
  • L339 · app.route("/api/v1/config")
  • L340 · app.route("/api/v1/preview")
  • L341 · app.route("/api/v1/counts")
  • L342 · app.route("/api/v1/subscribe")
  • L343 · app.route("/api/v1/auth")
  • L344 · app.route("/feed")
  • L345 · app.route("/c")
  • L346 · app.route("/")
  • L347 · app.route("/")
  • L348 · app.route("/")
  • L349 · app.route("/embed")
  • L350 · app.route("/admin")
  • L353 · app.route("/telegram")
  • L355 · app.get("/")

Environment references: c.env.ENV

src/routes/health.ts ↗
  • L6 · health.get("/")
src/routes/api.bootstrap.ts ↗
  • L125 · bootstrap.get("/")

Environment references: c.env.DB · c.env.EMAIL_FROM · c.env.PUBLIC_BASE_URL

src/routes/api.comments.ts ↗
  • L249 · comments.get("/form-token")
  • L436 · comments.post("/")
  • L1240 · comments.get("/")
  • L1288 · comments.get("/:id/source")
  • L1323 · comments.patch("/:id")
  • L1444 · comments.delete("/:id")

Environment references: c.env.SPAM_FORM_TS_SECRET · env.SPAM_FORM_TS_SECRET · env.SPAM_PROVIDER · env.DB · c.env.ANALYTICS · c.env.ENV · c.env.TURNSTILE_SECRET · c.env.DB · c.env.TURNSTILE_SITE_KEY · c.env.ALLOWED_ORIGINS

src/routes/api.reports.ts ↗
  • L47 · reports.post("/:id/report")

Environment references: c.env.ANALYTICS · c.env.DB

src/routes/api.config.ts ↗
  • L203 · config.get("/")

Environment references: env.TURNSTILE_SITE_KEY · env.TURNSTILE_SECRET · env.BRANDING_HIDDEN · env.EMAIL_FROM · env.PUBLIC_BASE_URL · env.SPAM_FORM_TS_SECRET

src/routes/api.page-engagement.ts ↗
  • L68 · pageEngagement.get("/")
  • L106 · pageEngagement.post("/reactions")
  • L163 · pageEngagement.post("/votes")

Environment references: c.env.DB · c.env.ANALYTICS

src/routes/api.reactions.ts ↗
  • L33 · reactions.post("/")

Environment references: c.env.DB · c.env.ANALYTICS

src/routes/api.votes.ts ↗
  • L48 · votes.post("/")

Environment references: c.env.DB · c.env.ANALYTICS

src/routes/auth.ts ↗
  • L109 · auth.get("/:provider/start")
  • L240 · auth.get("/:provider/callback")
  • L342 · auth.post("/signout")
  • L351 · auth.post("/session/exchange")
  • L402 · auth.get("/me")

Environment references: env.ENV · env.ALLOWED_ORIGINS · c.env.JWT_SECRET · c.env.ANALYTICS · env.ADMIN_EMAILS · c.env.DB · c.env.OAUTH_STATE

src/routes/embed.ts ↗
  • L27 · embed.get("/embed.js")
src/routes/agents.ts ↗
  • L46 · agents.get("/AGENTS.md")
  • L71 · agents.get("/AGENTS-OPERATE.md")
  • L28 · resolveInstance calls (conditional paths may differ): trimmedCanonical.replace

Environment references: c.env.CANONICAL_URL

src/routes/well-known.ts ↗
  • L55 · wellKnown.get("/.well-known/security.txt")

Environment references: c.env.CANONICAL_URL

src/routes/embed-iframe.ts ↗
  • L219 · iframe.get("/turnstile-frame")
  • L362 · iframe.get("/:slug")

Environment references: env.ALLOWED_ORIGINS · env.ENV · c.env.TURNSTILE_SITE_KEY

src/routes/admin.ts ↗
  • L283 · admin.use("*")
  • L308 · admin.use("*")
  • L310 · admin.get("/")
  • L363 · admin.get("/queue")
  • L500 · admin.get("/comments/:id")
  • L529 · admin.get("/users")
  • L561 · admin.get("/users/:id")
  • L605 · admin.get("/audit")
  • L696 · admin.get("/subscriptions")
  • L766 · admin.get("/operator")
  • L810 · admin.get("/telegram")
  • L835 · admin.post("/api/telegram/link")
  • L850 · admin.delete("/api/telegram/link")
  • L865 · admin.post("/api/telegram/digest")
  • L877 · admin.get("/settings")
  • L911 · admin.post("/settings")
  • L1086 · admin.get("/about")
  • L1109 · admin.get("/webhooks")
  • L1127 · admin.get("/webhooks/new")
  • L1137 · admin.get("/webhooks/:id")
  • L1269 · admin.post("/api/webhooks")
  • L1298 · admin.patch("/api/webhooks/:id")
  • L1340 · admin.get("/usage")
  • L1376 · admin.delete("/api/webhooks/:id")
  • L1460 · admin.get("/saved-replies")
  • L1480 · admin.get("/saved-replies/new")
  • L1495 · admin.get("/saved-replies/:id")
  • L1528 · admin.get("/api/saved-replies")
  • L1543 · admin.post("/api/saved-replies")
  • L1566 · admin.patch("/api/saved-replies/:id")
  • L1598 · admin.delete("/api/saved-replies/:id")
  • L1671 · admin.post("/api/notes")
  • L1700 · admin.delete("/api/notes/:id")
  • L1742 · admin.post("/api/comments/:id/reply")
  • L1891 · admin.post("/api/preview")
  • L1910 · admin.post("/api/comments/:id")
  • L1957 · admin.post("/api/posts/close")
  • L1992 · admin.post("/api/comments/:id/reports/resolve")
  • L2003 · admin.post("/api/comments/bulk")
  • L2079 · admin.post("/api/users/:id")

Environment references: c.env.DB · env.ENV · c.env.TELEGRAM_BOT_TOKEN · c.env.TELEGRAM_WEBHOOK_SECRET · c.env.TELEGRAM_BOT_USERNAME · c.env.OAUTH_STATE · env.WEBHOOK_URL · c.env.WEBHOOK_URL · c.env.CF_API_TOKEN · c.env.CF_ACCOUNT_ID · c.env.PUBLIC_BASE_URL · c.env.EMAIL_FROM · c.env.IP_HASH_SECRET

src/routes/telegram.ts ↗
  • L123 · telegram.post("/webhook")

Environment references: c.env.TELEGRAM_WEBHOOK_SECRET · c.env.TELEGRAM_BOT_TOKEN · env.DB · env.PUBLIC_BASE_URL · env.OAUTH_STATE

src/routes/feed.ts ↗
  • L76 · feed.get("/:slug")

Environment references: c.env.DB

src/routes/api.counts.ts ↗
  • L67 · counts.get("/")

Environment references: c.env.DB

src/routes/permalink.ts ↗
  • L29 · permalink.get("/:id")

Environment references: c.env.DB · c.env.ALLOWED_ORIGINS

src/routes/api.subscriptions.ts ↗
  • L128 · subscriptions.post("/")
  • L326 · subscriptions.get("/confirm/:token")
  • L365 · subscriptions.post("/confirm/:token")
  • L402 · subscriptions.get("/unsubscribe/:token")
  • L442 · subscriptions.post("/unsubscribe/:token")
  • L492 · subscriptions.post("/unsubscribe/:token/all")
  • L528 · subscriptions.post("/unsubscribe/:token/row/:id")
  • L609 · subscriptions.post("/unsubscribe/:token/one-click")
  • L728 · subscriptions.get("/mine")
  • L782 · subscriptions.delete("/mine/:id")

Environment references: c.env.DB · c.env.PUBLIC_BASE_URL · c.env.EMAIL_FROM

src/lib/digest.ts ↗

    Environment references: env.EMAIL_PROVIDER · env.RESEND_API_KEY · env.EMAIL_FROM · env.PUBLIC_BASE_URL · env.DB

    src/lib/moderator-digest.ts ↗

      Environment references: env.EMAIL_PROVIDER · env.RESEND_API_KEY · env.EMAIL_FROM · env.PUBLIC_BASE_URL · env.MODERATOR_NOTIFY_EMAILS · env.ADMIN_EMAILS · env.DB

      src/lib/telegram-digest.ts ↗

        Environment references: env.PUBLIC_BASE_URL · env.DB · env.TELEGRAM_BOT_TOKEN

        src/db/audit-retention.ts ↗

          Environment references: env.DB

          src/db/ip-retention.ts ↗

            Environment references: env.DB

            src/lib/active-user.ts ↗

              Environment references: c.env.DB

              src/lib/cf-usage.ts ↗

                Environment references: env.CF_API_TOKEN · env.CF_ACCOUNT_ID · env.TREE_CACHE

                src/lib/cors.ts ↗

                  Environment references: env.ALLOWED_ORIGINS · env.ENV

                  src/lib/email.ts ↗

                    Environment references: env.EMAIL_PROVIDER · env.RESEND_API_KEY

                    src/lib/ip-hash.ts ↗

                      Environment references: c.env.IP_HASH_SECRET · c.env.ENV

                      src/lib/moderation.ts ↗

                        Environment references: env.DB

                        src/lib/oauth.ts ↗

                          Environment references: env.OAUTH_CALLBACK_BASE

                          src/lib/ratelimit.ts ↗

                            Environment references: opts.env.RATE_LIMIT_DO

                            src/lib/require-config.ts ↗

                              Environment references: env.ENV

                              src/lib/session.ts ↗

                                Environment references: env.ENV · c.env.ENV · c.env.SESSIONS · env.SESSIONS

                                src/lib/settings.ts ↗

                                  Environment references: env.DB · env.TREE_CACHE

                                  src/lib/site-export.ts ↗
                                  • L137 · pages calls (conditional paths may differ): all, bind, db.prepare, rows.map
                                  • L156 · siteExportJson calls (conditional paths may differ): Date.now, JSON.stringify, toISOString, JSON_TABLES.entries, pages, join, rows.map
                                  • L190 · siteExportCsv calls (conditional paths may differ): csvRow, pages, join, rows.map, CSV_COLUMNS.map, toISOString
                                  src/lib/spam/index.ts ↗

                                    Environment references: env.SPAM_PROVIDER · env.AKISMET_API_KEY · env.AKISMET_SITE_URL · env.AI · env.RATE_LIMITS

                                    src/lib/version-check.ts ↗

                                      Environment references: env.GITHUB_TOKEN · env.TREE_CACHE

                                      src/lib/webhook.ts ↗

                                        Environment references: env.TELEGRAM_BOT_TOKEN · env.ENV · env.DB · env.PUBLIC_BASE_URL · env.WEBHOOK_URL

                                        src/admin-ui/components/spam-summary.ts ↗

                                          Environment references: env.SPAM_PROVIDER · env.SPAM_FORM_TS_SECRET

                                          src/admin-ui/pages/dashboard.ts ↗

                                            Environment references: env.PUBLIC_BASE_URL

                                            src/admin-ui/pages/settings.ts ↗

                                              Environment references: env.ENV · env.ALLOWED_ORIGINS · env.ADMIN_EMAILS · env.TURNSTILE_SITE_KEY · env.GH_CLIENT_ID · env.GOOGLE_CLIENT_ID · env.FACEBOOK_CLIENT_ID · env.TWITTER_CLIENT_ID · env.DISCORD_CLIENT_ID · env.OAUTH_CALLBACK_BASE · env.EMAIL_PROVIDER · env.SPAM_PROVIDER · env.AKISMET_API_KEY · env.AKISMET_SITE_URL · env.SPAM_FORM_TS_SECRET

                                              Build and deployment pipeline · 4 GitHub Actions workflows

                                              Repository CI declarations, separate from runtime request processing. Job dependencies and conditions are shown as written; long commands are shortened with an ellipsis; a workflow file does not prove a recent successful run.

                                              CI · .github/workflows/ci.yml ↗

                                              Triggers: push, pull_request, workflow_dispatch

                                              test · no job dependencies declared

                                              1. actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
                                              2. actions/setup-node@820762786026740c76f36085b0efc47a31fe5020actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
                                              3. Shell commandnpm ci
                                              4. Shell commandcp wrangler.example.toml wrangler.toml
                                              5. Shell commandnpm run lint
                                              6. Shell commandnpm run typecheck
                                              7. Shell commandnpm test
                                              8. Shell commandnpm run identity:check
                                              9. Shell commandnpm run manifest:check
                                              10. Shell commandnpm run config:check
                                              11. Shell commandnpm run build
                                              12. Shell commandnpm run size
                                              13. Shell commandnpm run size:deltaCondition: github.event_name == 'pull_request'
                                              CodeQL · .github/workflows/codeql.yml ↗

                                              Triggers: push, pull_request, schedule

                                              Analyze (${{ matrix.language }}) · no job dependencies declared

                                              1. actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
                                              2. github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2
                                              3. github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2
                                              Docs sync · .github/workflows/docs-sync.yml ↗

                                              Triggers: pull_request, workflow_dispatch

                                              ${{ matrix.name }} · no job dependencies declared

                                              1. actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
                                              2. Check ${{ matrix.name }}set -euo pipefail CHANGED=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA") echo "Changed files in this PR:" echo "$CHANGED" echo "" WATCHED_HIT=$(echo "$CHANGED" | grep -E "$WATCH_REGEX" || true) SATISFY_HIT=$(echo "$CHANGED" | grep -E "$SATISFY_REGEX" || true) if [ -n "$WATCHED_HIT" ] && [ -z "$SATISFY_HIT" ]; then # Gates that set var_paths narrow the trigger to an actual change in # the set of variable names, so editing a comment or a placeholder # value in a config template doesn't demand a …Condition: github.event_name == 'pull_request' && !contains(github.event.pull_request.labels.*.name, matrix.label)
                                              Release · .github/workflows/release.yml ↗

                                              Triggers: push, workflow_dispatch

                                              release · no job dependencies declared

                                              1. actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
                                              2. actions/setup-node@820762786026740c76f36085b0efc47a31fe5020actions/setup-node@820762786026740c76f36085b0efc47a31fe5020
                                              3. Shell commandnpm ci
                                              4. Shell commandcp wrangler.example.toml wrangler.toml
                                              5. Shell commandnpm run lint
                                              6. Shell commandnpm run typecheck
                                              7. Shell commandnpm test
                                              8. Shell commandnpm run manifest:check
                                              9. Shell commandnpm run build
                                              10. Shell commandnpm run size
                                              11. Generate SHA256SUMS# Basenames, so the entries match the asset filenames on the release # page and `sha256sum -c SHA256SUMS` works in a directory of # downloaded assets. ( cd dist && sha256sum embed.js embed.js.map ) > SHA256SUMS sha256sum release-manifest.json >> SHA256SUMS echo "::group::SHA256SUMS (compare release assets against this)" cat SHA256SUMS echo "::endgroup::"
                                              12. softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64
                                              package.json ↗
                                              • deploy: wrangler deploy
                                              • predeploy: npm run build:assets
                                              • build: wrangler deploy --dry-run --outdir=dist
                                              • prebuild: npm run build:assets
                                              • build:styles: tsx scripts/build-styles.ts
                                              • prebuild:embed: npm run build:styles
                                              • build:embed: tsx scripts/build-embed.ts
                                              • build:agents: tsx scripts/build-agents-md.ts
                                              • build:assets: npm run build:embed && npm run build:agents && npm run build:version
                                              • migrate: tsx src/db/migrate.ts
                                              • build:version: tsx scripts/build-version.ts

                                              Full upstream document by @KingPin · README.md · snapshot 1a18813

                                              Garrul

                                              Sponsor Ko-fi License: Apache 2.0

                                              Self-hosted comments for static sites and blogs. Runs on Cloudflare Workers + D1 + KV + Turnstile. One Worker per site, no per-comment billing, your data stays in your account.

                                              Try the live demo →

                                              Self-hosted, but not the hard kind: there is no container, no VPS, and no database server. Nothing to patch, no uptime to monitor, no TLS to renew, and Cloudflare keeps point-in-time backups of D1 for you. It is one Worker, and keeping it current is a single dry-runnable command per release. Two credentials to get started (a Turnstile key pair, from one dashboard page); every other integration is optional.

                                              • Threaded comments: markdown, reactions, an edit/delete window, and a newest/oldest/top sort readers can switch
                                              • OAuth sign-in (GitHub, Google, Facebook, X, Discord) plus anonymous posting, rate-limited and Turnstile-gated
                                              • Embeddable widget: CI-capped at 30 KB gzipped, Shadow-DOM isolated, themeable, with an iframe alternative
                                              • Reply notifications by email, built in: readers opt in from the widget, confirm by double opt-in, get a debounced digest, and leave in one click from Gmail's own Unsubscribe button. Bring a Resend key (docs/notifications.md)
                                              • Moderator notifications by email: a digest of what's queued or reported. Off by default; one switch in Settings → Moderation
                                              • Layered anti-spam: Turnstile, rate limiting and a strict markdown sanitizer always on, four tunable heuristics and an optional classifier on top, everything routed to the queue (docs/ANTISPAM.md)
                                              • Import from Disqus, Remark42, Comentario, isso or Cusdis: upload the export in the admin UI — the .xml.gz Disqus hands you, the userbackup-<site>-<ts>.gz Remark42 writes nightly, or Comentario's JSON export, no unzipping — or run npm run import-disqus -- ./export.xml.gz --dry-run / npm run import-remark42 -- ./userbackup.gz --dry-run / npm run import-comentario -- ./export.json --dry-run first. The Comentario reader also takes a legacy Commento export. isso and Cusdis ship no export at all, so they're two steps — npm run dump-isso -- ./comments.db --out dump.json or npm run dump-cusdis -- ./db.sqlite --out dump.json to read the SQLite store, then either npm run import-isso / npm run import-cusdis -- ./dump.json --dry-run or upload that same dump.json on the admin UI same as the other sources (see docs/importing.md). Idempotent, so a re-run inserts nothing; closed threads stay closed and spam stays out of the public tree
                                              • Admin UI: moderation queue, user management, and settings you change without a redeploy
                                              • RSS feeds, comment counts, permalinks
                                              • Webhook out on every comment event: generic, Slack, Discord, or Telegram
                                              • Telegram operator bot: moderate from your phone with inline buttons, /queue and /stats, optional daily digest
                                              • Pinned comments — moderators pin one top-level comment per post; it shows first on page one
                                              • Staff badge — moderators and admins can mark a comment as posted by staff; the label is configurable
                                              • Choose your reactions — pick which of 12 reaction emoji appear on comments and on the page bar, in any order
                                              • Reactions-only mount — data-mode="reactions" renders just the page reaction/vote bar, in one request
                                              • Full-site export — admins download every post, comment and user as JSON, or comments as CSV
                                              • One-command install — npm run setup creates resources, sets secrets and vars, migrates, deploys and checks health

                                              Every doc in the repo, grouped by task: docs/README.md.

                                              Screenshots

                                              What your readers see, rendered inside a Shadow DOM so the host page's CSS can't collide with it:

                                              Garrul comment widget on a host page, showing the page reaction bar, the markdown composer, and a threaded discussion

                                              The admin dashboard, with counts at a glance, your embed snippet ready to copy, and 30-day comment volume:

                                              Admin dashboard showing comment and user counts, the embed snippet, and a comments-per-day chart

                                              The moderation queue, where comments are approved, marked spam or deleted inline:

                                              Moderation queue listing pending comments with author, body, metadata and per-row actions

                                              More, including dark mode, mobile, and the rest of the admin UI: docs/screenshots.md.

                                              Install

                                              Deploying to production takes ~20 minutes the first time, and then npm run upgrade per release: one command with a --dry-run that prints its plan before touching anything. That is the whole maintenance story; there is no OS to patch and no service to restart.

                                              The step-by-step guide covers prerequisites, OAuth setup, Turnstile, custom domain, remote migrations, deploy and smoke test: INSTALL.md. Upgrades are in INSTALL.md#updating.

                                              Want to try it first? No Cloudflare account needed. .dev.vars.example ships working dev defaults, including Cloudflare's "always passes" Turnstile test keys, so this runs with zero edits and zero credentials:

                                              git clone https://github.com/KingPin/Garrul.git comments
                                              cd comments
                                              npm install
                                              cp wrangler.example.toml wrangler.toml
                                              cp .dev.vars.example .dev.vars
                                              npm run migrate           # local Miniflare DB
                                              npm run dev               # http://localhost:8787
                                              

                                              Embedding

                                              Drop the widget into any page:

                                              <div
                                                id="garrul"
                                                data-slug="my-post-slug"
                                                data-api="https://comments.example.com"
                                                data-title="My post title"
                                                data-url="https://example.com/my-post/"
                                                data-published="2026-09-11T12:00:00Z"
                                              ></div>
                                              <script src="https://comments.example.com/embed.js" defer></script>
                                              

                                              Copy-paste recipes for Astro, Hugo, Jekyll, WordPress, plain HTML and the iframe variant, plus what each attribute does: examples/README.md.

                                              Host Content-Security-Policy, the iframe fallback, data-lang, lazy-loading to cut the mount requests bouncers cost you, and pointing an AI assistant at your instance: docs/embedding.md.

                                              Running it

                                              • Theming: the widget mounts in Shadow DOM, so host-page CSS doesn't leak in. Restyle by overriding CSS custom properties on the host element; those names are part of the public, semver-protected API (docs/THEMING.md).
                                              • Admin UI: /admin, for an OAuth sign-in whose email is in ADMIN_EMAILS.
                                              • Logs: wrangler tail. Every request emits a JSON line with a request id. No PII (names, emails, comment bodies) is logged.
                                              • Metrics: Workers Analytics Engine writes comment.posted, oauth.complete, ratelimit.hit and friends; read them in the Cloudflare dashboard under your Worker.
                                              • Backups: npm run db:export writes a .sql dump for your local archive. Cloudflare keeps point-in-time backups of D1 as well. /admin/operator → Export site data downloads a portable JSON backup or a comments CSV.
                                              • Re-render: bumped the markdown sanitizer? npm run rerender rewrites stored comment HTML in place.

                                              Day-to-day operation in full: AGENTS-OPERATE.md.

                                              Anti-spam

                                              Always on, with no configuration at all: a sliding-window rate limit on the edge Cache API, a strict markdown sanitizer (no raw HTML, no images, every link nofollow ugc noopener), and a hidden-field honeypot. Turnstile covers anonymous posts once the Turnstile keys are set, and TURNSTILE_ALWAYS challenges signed-in authors too.

                                              On top of those, four heuristics: minimum fill time, link count, hold an author's first comment, and a muted-words list with word-boundary and wildcard terms. All off by default, each with an env var that sets the deploy-time default, and all four retunable from Settings → Moderation without a redeploy. Optionally a content classifier (Akismet or Workers AI) runs when no heuristic has already flagged.

                                              Nothing is ever silently dropped. Every layer routes to /admin/queue?status=pending; you decide what gets approved. The muted-words grammar, the env var behind each heuristic, and the Turnstile mount timing with its four visitor-facing messages: docs/ANTISPAM.md.

                                              Access control

                                              Your instance is gated by ALLOWED_ORIGINS (set in wrangler.toml, comma-separated, no wildcards). Every request under /api/*, including plain GET reads of comment trees, counts, and config, must carry a matching Origin header. Browser fetches from your own sites send it automatically; direct curl or scraper hits return 403 err.origin.forbidden. Uptime probes, the OAuth callbacks, the Atom feed, permalinks and embed.js are reachable without one.

                                              The matching rules, the full exemption list, a curl test recipe, and what a build-time fetcher should read instead: AGENTS-OPERATE.md.

                                              If you set a vulnerability-disclosure contact (Admin → Settings, or the SECURITY_CONTACT var), your instance publishes it at /.well-known/security.txt (RFC 9116) — the standard place security researchers look for where to report a problem. Until then the route answers 404.

                                              Privacy

                                              Garrul stores:

                                              • Comment bodies + author names
                                              • Email addresses (OAuth users, and subscribers who opted in to digests)
                                              • HMAC-SHA-256 hashed IP addresses (never the raw IP) and user-agent strings
                                              • Provider IDs and avatar URLs for OAuth users

                                              No analytics, no tracking pixels, no advertising, no Gravatar. One strictly-necessary cookie. Data-subject requests are served by a per-user JSON export and an admin erase panel, both on /admin/users/<id>.

                                              Running an instance that European or Californian readers comment on makes you the controller of that data, not this project. docs/privacy-policy.template.md and docs/tos.template.md are yours to fill in and link from your footer, and docs/compliance/ has the paperwork that follows: a personal-data inventory, the data-subject rights mapped to the mechanisms that serve them, CCPA/CPRA categories, a subprocessor register, and a DSAR runbook. Not legal advice, and it does not claim Garrul "is GDPR compliant": compliance is a property of a deployment.

                                              Troubleshooting

                                              docs/troubleshooting.md is symptom-by-symptom across setup, embedding, OAuth, cookies and sessions, notification email, and performance. The two that bite most often: Safari readers can't sign in unless you serve over HTTPS, because cookies are SameSite=None; Secure; Partitioned; and *.workers.dev shouldn't be used in production, so map a custom subdomain.

                                              Contributing

                                              Bug reports and PRs welcome. See CONTRIBUTING.md. Project conventions and code layout are documented in CLAUDE.md.

                                              License

                                              Apache 2.0. See LICENSE and NOTICE.

                                              Frequently asked about Garrul

                                              What is Garrul?+

                                              Garrul is a self-hosted Comentario/Commento alternative built on the Cloudflare developer platform. Self-hosted comments for static sites: a Cloudflare alternative to Disqus, with moderation and migration tools.

                                              What does Garrul replace?+

                                              Garrul is listed as an alternative to Comentario, Commento, Cusdis, Disqus, Isso, Remark42. Compare the features and tradeoffs before migrating.

                                              What Cloudflare primitives does Garrul use?+

                                              Garrul is built on Analytics Engine, Cache API, D1, KV, Turnstile, Workers.

                                              How much does Garrul cost to run?+

                                              The baseline Worker, D1, KV and Analytics Engine configuration can fit Cloudflare Free allowances for a modest comment workload. Usage is shared across the account; email, optional AI services and a custom domain can add costs. Eligibility is source-reviewed, not a deployed load test. Workers Free permits 100,000 requests per day and 10 ms CPU per invocation. Measure real embed traffic, imports and cron jobs; do not equate site visitors with Worker requests. D1 Free includes 5 million rows read and 100,000 rows written per day with 5 GB total storage. Scan-heavy dashboards, comment history and migrations consume these allowances. KV Free includes 100,000 reads and 1,000 writes per day with 1 GB storage across the account. Sessions and one-time handoff tokens use KV; the default response cache and limiter use the Cache API. Analytics Engine currently lists 100,000 data points and 10,000 read queries per day on Workers Free. Its pricing page says billing is not yet active and may change. Configure production secrets, resource IDs, site origins and at least one OAuth provider that returns an administrator email. Anonymous posting additionally requires a real Turnstile key pair. Optional reply/moderator email needs Resend and a verified sender domain; optional AI/Akismet classification and integrations have their own limits and charges. A workers.dev address is documented, but the source recommends a custom subdomain to reduce third-party-cookie friction. Domain purchase and browser-specific login behavior are outside the free-hosting claim. Check current Cloudflare pricing before deploying.

                                              Is Garrul open source?+

                                              The upstream repository declares the Apache-2.0 license. Read its terms at https://raw.githubusercontent.com/KingPin/Garrul/1a18813af9e822bf53a7b81c2fd681e2d9bf8aac/LICENSE. Source code and contributor credit are available at https://github.com/KingPin/Garrul.

                                              Community rating

                                              No ratings yet. Tried this project? Share your experience.

                                              One rating per verified account. You can change or remove yours. Accounts are email verified; use of the software is self-reported.

                                              Discuss your experience ↓
                                              Sign in to rate

                                              Discussion · 0

                                              sign in to comment →
                                              No comments yet — be the first.