Forge
Forge is a Go framework for building HTTP services on PostgreSQL, MySQL,
MariaDB, or SQLite with secure defaults, a conventional application layout,
and a production container from the first commit. The core packages use only
the Go standard library; each database adapter adds its driver.
New to Forge? Getting started builds a small blog
from forge new to deploy: scaffolds, sign-in, live updates, translations,
email, jobs, OpenAPI and tests.
Status
Pre-release. Phases 1 (core runtime, configuration, CLI, lifecycle, HTTP) and
2 (databases, migrations, transactions, repositories) are implemented,
including support for several databases. Phase 3 provides an identity and
tenancy foundation with PostgreSQL, MySQL/MariaDB and SQLite persistence, password and browser-session
flows, recovery and MFA primitives, security auditing, generated sign-in
pages and a setup command for the first owner. Phase 4 adds
background jobs, a transactional outbox with bounded concurrent dispatch,
dead-letter replay, and an in-process scheduler. Its remaining scope and
adapter boundaries are listed in the roadmap. APIs may change
without notice until a tagged release.
Phase 7 now includes a provider-neutral agent action engine and a
PostgreSQL-backed execution journal. It does not call an LLM: applications
register actions and supply policy, approval, and identity integrations. The
engine takes identity only from authenticated middleware, checks tenant
consistency, and durably marks uncertain post-execution outcomes for
reconciliation. Policy and approval callbacks remain application trust points.
Destructive/external actions require an approval adapter; the CLI itself is
not an agent authorization boundary. See the AI agent guide.
The PostgreSQL transactional outbox supports bounded concurrent delivery,
leases, acknowledgements, bounded retries, dead-letter replay, and queue
metrics. An in-process job scheduler (jobs.Scheduler) runs recurring work
with configurable concurrency. Phase 5 adds dependency-free observability:
traces with W3C propagation, bounded-cardinality metrics for Prometheus and
OTLP, trace-correlated logs, and database and job instrumentation
(docs/OBSERVABILITY.md). Phase 6 starts with an
OpenAPI 3.1 document generated from the routes' Go types
(docs/OPENAPI.md). Phase 10 starts with continuous
fuzzing, SBOMs and signed release images
(docs/SUPPLY_CHAIN.md). Performance work and further
security hardening remain planned. See docs/ROADMAP.md for the full status.
Packages
Core packages import only the standard library and each other; a test
(architecture_test.go) enforces this
(ADR 0007,
ADR 0009), and the dependency
directions of ADR 0052: auth speaks no
HTTP, and only applications import the root forge package.
| Package |
Purpose |
forge |
forge.Main entrypoint, App composition, shutdown hooks, binary subcommands, health routes |
command |
Command, Env and ErrUsage, the contract of binary subcommands (forge.Command is the same type) |
auth |
Opaque session tokens, principals and deny-by-default permissions, password hashing, sign-in, MFA, password recovery, throttling and the security audit, with the identity-store ports; no HTTP |
auth/authhttp |
The HTTP side of auth: bearer and cookie session middleware (authhttp.NewMiddleware), Require/RequireAudited/RequireCSRF, the JSON sign-in, MFA and recovery handlers with their OpenAPI, browser sign-in, sign-out, MFA and password-reset flows (authhttp.Browser) and sign-in redirects for HTML pages |
agent |
Provider-neutral allowlisted action pipeline with policy, validation, principal permissions, explicit approval and mandatory journaling |
tenancy |
Fail-closed tenant context; used by authentication and tenant-aware database transactions |
config |
Environment configuration with fail-fast validation; database selection by URL scheme; config.Secret for redacted values |
credentials |
Encrypted per-environment credentials (config/credentials/<env>.json.enc, AES-256-GCM, key from FORGE_MASTER_KEY or a git-ignored key file), typed Store, fail-fast required credentials; forge credentials edit |
encryption |
Attribute encryption with key rotation: randomized AES-256-GCM bound to table, column and record id, key rings, opt-in HMAC blind index, Value[T] for database/sql, resumable batch re-encryption |
router |
Route registry on net/http.ServeMux (Go 1.22 patterns), frozen after the first request; JSON 400/404/405; conventional Resources routes |
openapi |
OpenAPI 3.1 from described routes: Describe, Typed handlers, schemas reflected from Go types (UUIDv7, date-time, nullable, $ref components), deterministic JSON, contract validation of responses |
httpserver |
HTTP server with timeouts, body limit, security headers, request IDs, panic recovery, graceful shutdown, and composable application middleware |
web |
JSON responses, the single error format, strict JSON and form decoding, safe redirects, content negotiation, flash messages, method override, request ID and request-scoped logger |
csrf |
CSRF protection for browser requests: double-submit token in forms and headers, cross-origin rejection |
views |
Concurrent-safe server-rendered HTML with layouts, partials, CSRF, flash and translation helpers, contextual escaping and buffered rendering |
i18n |
Rails I18n: JSON translations in config/locales, %{name} interpolation, CLDR plural rules, per-request locale (path, parameter, cookie, Accept-Language), localized numbers and dates, forge i18n check |
pagination |
Keyset pagination over UUIDv7 with opaque cursors |
events |
Versioned event envelope with bounded JSON payloads |
jobs |
In-process scheduler with bounded concurrency, fixed-rate, daily, and numeric 5-field cron schedules |
ratelimit |
Rails-style rate_limit: fixed-window limiter, per-route 429/Retry-After middleware, bounded in-memory store; shared stores in ratelimit/postgres, /mysql, /sqlite |
cache |
Solid Cache equivalent: Cache with Fetch stampede protection, bounded LRU+TTL memory cache, typed JSON helpers; shared stores in cache/postgres, /mysql, /sqlite |
storage |
Active Storage equivalent for local disk: Storage port, HMAC-signed expiring links, safe serving handler, strict multipart upload; storage/disk adapter |
telemetry |
Tracing with W3C trace context, metrics with a cardinality cap, Prometheus text and OTLP/HTTP JSON export, trace-correlated logs |
health |
Readiness check registry; /health, /health/live, /health/ready |
uuid |
RFC 9562 UUIDv7 (default identifier), monotonic generator, JSON/text/SQL support |
fault |
Typed application errors (code, message, category, cause, metadata, retryable, HTTP status) |
sqldb |
Database-independent error translation, transaction retries, bounded pool shutdown, database/sql wrapper with a sqlc-compatible DBTX |
migrate |
Migration engine (SQL files, checksums, locks, migrate/rollback commands) with a per-database Dialect |
dbtest |
Test-server discovery shared by the adapters' test packages |
Adapter packages (third-party dependencies allowed):
| Package |
Purpose |
postgres |
PostgreSQL on pgx v5: pool, DBTX, transactions with retry, error translation, advisory locks, migrations |
outbox/postgres |
PostgreSQL transactional outbox persistence and concurrent dispatcher |
agent/postgres |
Tenant-RLS-protected PostgreSQL journal with replay refusal, bounded reconciliation listing, and one-time operator decisions |
mysql |
MySQL and MariaDB on go-sql-driver/mysql: hardened session, error translation, migrations |
sqlite |
SQLite on the pure-Go modernc.org/sqlite: enforced pragmas, error translation, migrations with a file lock |
ratelimit/{postgres,mysql,sqlite}, cache/{postgres,mysql,sqlite} |
Shared rate limit buckets and application cache in the database, with embedded migrations |
postgres/postgrestest, mysql/mysqltest, sqlite/sqlitetest |
A fresh database per test |
An application links only the adapter it imports. The forge CLI lives in
cmd/forge; the deploy proxy, forge-proxy, in cmd/forge-proxy (its
go.yaml.in/yaml/v3 and golang.org/x/crypto dependencies are not linked
into applications, ADR 0030). The core
packages import no third-party module: Argon2id password hashing
(golang.org/x/crypto/argon2) comes with the identity stores auth/postgres,
auth/mysql and auth/sqlite, which register it, and an application with
another store calls auth.RegisterArgon2id(argon2.IDKey); with neither, new
passwords are PBKDF2-HMAC-SHA256 (ADR 0053,
password hashing).
Install
Forge-Go ships as a Go module and, from a v* tag, as signed binaries. There is
no installer script to pipe into a shell: you either build a pinned version
with Go or download an archive and verify it first.
With Go 1.27.3+; the Go checksum database verifies the module:
go install github.com/rocketinsidecorp/forge-go/cmd/forge@latest
forge version # forge v0.1.0 (unknown, unknown, go1.27.3 linux/amd64)
To pin a release, go install github.com/rocketinsidecorp/forge-go/cmd/forge@vX.Y.Z
with a version from the releases.
A build from the module proxy reports unknown for the commit and the date;
they are stamped into the release archives below, which is why forge version
there shows both.
Without Go, from a release archive: forge and forge-proxy for linux,
darwin and windows on amd64 and arm64 (.tar.gz, .zip on Windows),
named forge_<version>_<os>_<arch>. Each release carries checksums.txt
(SHA-256 of every archive and SBOM), checksums.txt.sigstore.json (its keyless
cosign signature) and a GitHub build provenance attestation that covers every archive and SBOM. Download,
verify (cosign v3+, gh 2.68+), then install:
VERSION=vX.Y.Z OS=linux ARCH=amd64 # a release tag; OS: linux, darwin, windows; ARCH: amd64, arm64
BASE=https://github.com/rocketinsidecorp/forge-go/releases/download/$VERSION
curl -sSLO "$BASE/forge_${VERSION}_${OS}_${ARCH}.tar.gz"
curl -sSLO "$BASE/checksums.txt"
curl -sSLO "$BASE/checksums.txt.sigstore.json"
# 1. checksums.txt was signed by this repository's release workflow, for a v* tag
cosign verify-blob --bundle checksums.txt.sigstore.json \
--certificate-identity-regexp '^https://github\.com/rocketinsidecorp/forge-go/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
checksums.txt
# 2. the archive is the one checksums.txt lists (macOS: grep " forge_${VERSION}_${OS}_${ARCH}" checksums.txt | shasum -a 256 -c)
sha256sum -c --ignore-missing checksums.txt
# 3. GitHub attests this repository's release.yml built it, from the tag $VERSION
gh attestation verify "forge_${VERSION}_${OS}_${ARCH}.tar.gz" --repo rocketinsidecorp/forge-go \
--signer-workflow rocketinsidecorp/forge-go/.github/workflows/release.yml --source-ref "refs/tags/$VERSION"
tar -xzf "forge_${VERSION}_${OS}_${ARCH}.tar.gz" # Windows: Expand-Archive forge_..._windows_amd64.zip
sudo install -m 0755 "forge_${VERSION}_${OS}_${ARCH}/forge" /usr/local/bin/forge
forge version
forge-proxy_<version>_<os>_<arch> verifies the same way. Verifying a
single-release identity, an offline attestation bundle, the SBOMs and the
signed container images: docs/SUPPLY_CHAIN.md.
Maintainers cut a release with docs/RELEASING.md.
Quickstart
Requires Go 1.27.3+ and Docker with BuildKit and Compose, and the forge
command (Install).
go install github.com/rocketinsidecorp/forge-go/cmd/forge@latest
forge new myapp # SQLite; or -d postgresql, -d mysql, -d mariadb, -d none
cd myapp
docker compose up --build # or, without Docker: forge migrate && forge dev
curl -i http://127.0.0.1:8080/v1/status
curl -i http://127.0.0.1:8080/health/ready # includes the "database" check
Authentication and organizations are opt-in (owner decisions of 2026-10-26):
forge new site # SQLite, no sign-in: a welcome page and plain tables
forge new shop --auth # SQLite with sign-in, two-factor and password-reset pages
forge new saas --tenants # PostgreSQL (row-level security), sign-in, organizations
forge new saas --tenants -d mysql # organizations on MySQL: isolation by mandatory org_id predicates
forge new shop -d mariadb --auth # any -d works with --auth
Like Rails' --skip-docker, and with the same names as Forge-Rust, the
container and CI files are optional:
forge new shop --skip-docker # no Dockerfile, .dockerignore, compose.yaml, compose.observability.yaml, docker/ or observability/ (and no image job in CI)
forge new shop --skip-ci # no .github/ (no CI, release workflow or Dependabot)
forge new shop -d postgresql --skip-docker --skip-ci # neither; start the PostgreSQL server yourself
--tenants implies --auth and selects PostgreSQL unless -d is given.
An application grows later without edits: forge generate authentication
adds sign-in to one created without --auth, and forge generate tenancy
turns an --auth application into one with organizations (below).
An API-only application, like rails new --api: JSON handlers with no views,
assets, JavaScript, translations, sign-in pages or browser middleware (CSRF,
method override, flash). Every -d works with it.
forge new myapi --api # or: forge new myapi --api -d postgresql
cd myapi
docker compose up --build # or, without Docker: forge migrate && forge dev
curl -s http://127.0.0.1:8080/ # {"application":"myapi","version":"0.1.0"}
curl -s http://127.0.0.1:8080/v1/status # {"service":"myapi","status":"ok"}
curl -s http://127.0.0.1:8080/health # JSON liveness and readiness
curl -s http://127.0.0.1:8080/openapi.json
forge generate resource post title:string # a JSON API under /v1/posts (closed until you write its guard, or --public)
forge generate controller status_checks index --api # read-only JSON endpoints
forge generate scaffold post title:string # refused: an --api application has no pages
--database (-d) |
Compose service |
Adapter |
sqlite (default; also sqlite3) |
none; the database file is on a storage volume |
sqlite |
postgresql (default with --tenants; also postgres, pg) |
postgres:18-alpine |
postgres |
mysql |
mysql:8.4 |
mysql |
mariadb |
mariadb:11.8 |
mysql |
none (same as --skip-database) |
none |
none |
docker compose up starts the database server (if any), runs the
application's migrate command once, then starts the application. A database
server is published on 127.0.0.1:<port>, a port between 20000 and 29999
derived from the application name so it does not clash with a local server
on its default port; the generated README and .env.development contain the
exact value.
Running the application outside Docker (server databases):
docker compose up -d postgres # or mysql, mariadb
forge db prepare # create the database if missing, then migrate (and seed a new one)
forge dev # rebuilds, restarts and reloads the browser when files change
With SQLite, forge db prepare and forge dev use storage/development.db
directly.
End to end: a CRUD with sign-in, no code edited
The generated README carries these sequences too. SQLite with sign-in, no
Docker:
forge new shop --auth
cd shop
forge migrate
forge setup --email you@example.com
forge generate scaffold product name:string price:integer
forge migrate
forge dev
forge setup asks for the owner's password twice (or reads
FORGE_SETUP_PASSWORD). Open http://localhost:8080/session/new, sign in
with your email and password, then list, create, edit and delete products at
http://localhost:8080/products. The scaffold mounted itself in
app/bootstrap/routes_gen.go behind sign-in and the products:read and
products:write permissions its migration grants to the owner role. Field
types take the names Rails uses too: integer, boolean and timestamp
(or datetime) are int, bool and time.
With organizations (PostgreSQL, row-level security), every user signs in to
an organization, and every scaffolded row belongs to one:
forge new saas --tenants
cd saas
docker compose up -d --wait postgres
forge migrate
forge setup --org acme --email you@example.com
forge generate scaffold product name:string price:integer --org-scoped
forge migrate
forge dev
Sign in with organization acme. Tables are --org-scoped (an org_id
column every query and policy filters by) or --global (shared by every
organization). The JSON API at /v1/products authenticates the same session
cookie (and, in an application created with --api-auth, bearer tokens);
changes send the CSRF token in the X-CSRF-Token header (drop
--data-urlencode tenant=acme in an application without organizations):
jar=$(mktemp)
token=$(curl -s -c "$jar" -H 'Accept: text/html' http://localhost:8080/session/new | sed -n 's/.*name="authenticity_token" value="\([^"]*\)".*/\1/p' | head -n 1)
read -rsp 'Password: ' password; echo
curl -s -o /dev/null -b "$jar" -c "$jar" -H 'Accept: text/html' --data-urlencode "authenticity_token=$token" --data-urlencode tenant=acme --data-urlencode email=you@example.com --data-urlencode "password=$password" http://localhost:8080/session
curl -s -b "$jar" http://localhost:8080/v1/products
curl -s -b "$jar" -H "X-CSRF-Token: $(awk '$6 == "__Host-forge_csrf" {print $7}' "$jar")" -H 'Content-Type: application/json' -d '{"name":"Lamp","price":1999}' http://localhost:8080/v1/products
MySQL and MariaDB work the same way (forge new shop -d mysql --auth, then
docker compose up -d --wait mysql or mariadb after cd).
An application without --auth has no sign-in, so generated routes are
closed (403) unless generated with --public, which serves them to anyone
who can reach the server (forms still need their CSRF token):
forge new shop
cd shop
forge migrate
forge generate scaffold product name:string price:integer --public
forge migrate
forge dev
Then open http://localhost:8080/products, or
curl -s http://localhost:8080/v1/products. To put your own authentication in
front, copy the guard function (public, or protect for routes generated without --public) from app/bootstrap/routes_gen.go into another file of app/bootstrap, change it there, and run any forge generate command again (repeating the last one is enough): until then the build fails with public redeclared, never silently; afterwards the generators leave their own out and use yours.
Or add Forge's: forge generate authentication, then forge migrate and
forge setup --email you@example.com.
Growing an --auth application into one with organizations converts every
generated table: a migration adds org_id to each (filled with the one
organization forge setup created, then indexed and NOT NULL; on
PostgreSQL with row-level security forced), the generated code is rewritten
as --org-scoped would write it, and sign-in starts asking for the
organization. It refuses files you edited and runs backwards with
forge rollback, which refuses (changing nothing) while a second
organization exists:
forge generate tenancy --org acme --dry-run # the migration and every file it rewrites
forge generate tenancy --org acme # the default organization becomes acme
forge migrate
CI runs the SQLite, MySQL and PostgreSQL sequences above as written, with a
real forge binary, and drives them over HTTP: sign-in, the CRUD pages, the
JSON API and, with organizations, a second organization that gets 404 for
every row of the first (TestGeneratedApplication/readme_quickstart_*).
Database roles (PostgreSQL)
A PostgreSQL application serves as a runtime role that row-level security
applies to and that cannot change the schema, and migrates as a separate role
that owns it. docker/postgres/init.sql creates both in the Compose database
(app_runtime and app_migrator, development-only passwords), and
.env.development points FORGE_DATABASE_URL and
FORGE_MIGRATION_DATABASE_URL at them:
docker compose up -d postgres # first start: init.sql creates app_migrator, app_runtime and the database
forge migrate # runs as app_migrator (FORGE_MIGRATION_DATABASE_URL)
forge dev # serves as app_runtime (FORGE_DATABASE_URL)
forge test # repository and sign-in tests run as a fresh runtime role
docker compose down --volumes # once, for a volume created before the roles existed
Only forge migrate and forge rollback read PGOPTIONS, as for psql; every command that
opens the application's pool (forge dev, seed, jobs, setup) refuses to
start while it is set, naming the variable and never its value, because a
forge.tenant_id in it would give every session a default tenant:
PGOPTIONS='-c lock_timeout=5s' forge migrate # works: bounds lock waits for this run
PGOPTIONS='-c lock_timeout=5s' forge dev # refused: PGOPTIONS is set in the environment, ...
In production, provision the same two roles once (SQL in
Database roles), migrate with
the migration role and serve with the runtime role only. In staging and
production the application refuses to start when the role it logs in as, or
acts as, is a superuser or has BYPASSRLS or CREATEROLE, holds TRUNCATE,
REFERENCES or TRIGGER on a table with row-level security (a GRANT ALL),
or when the URL switches it to another role. postgres.Open does this for
every pool, also in a hand-written application; a pool that must use such a
role on purpose (a reporting tool, never the serving pool) opts out in code
with postgres.Open(ctx, cfg, postgres.AllowRowSecurityBypass()).
docker run --rm -e FORGE_ENV=production \
-e FORGE_DATABASE_URL="$RUNTIME_DATABASE_URL" \
-e FORGE_MIGRATION_DATABASE_URL="$MIGRATION_DATABASE_URL" myapp migrate
docker run -e FORGE_ENV=production -e FORGE_DATABASE_URL="$RUNTIME_DATABASE_URL" myapp
With forge deploy, name the migration credential under migrate.env in
config/deploy.yml and keep it out of env: only the one-off migrate
container receives it (DEPLOYMENT.md):
env:
secret: [FORGE_DATABASE_URL] # every container
migrate:
env:
secret: [FORGE_MIGRATION_DATABASE_URL] # the migrate step only
# .forge/secrets: FORGE_MIGRATION_DATABASE_URL=$FORGE_MIGRATION_DATABASE_URL
export FORGE_DATABASE_URL="$RUNTIME_DATABASE_URL" FORGE_MIGRATION_DATABASE_URL="$MIGRATION_DATABASE_URL"
forge deploy --dry-run # prints the upload, run and rm commands for the migrate env file; values stay hidden, nothing runs
forge deploy
Development loop
forge dev # http://127.0.0.1:8080, reloads on save
forge dev --poll-interval 1s # scan less often (large trees, slow disks)
forge dev --no-reload # build and run once, no watching
forge db setup # create + migrate + seed
forge db prepare # setup if the database is missing, else migrate (safe to repeat)
forge db reset # drop + setup: a clean development database
forge db status # applied / pending migrations
forge db rollback -steps 1 # your newest migration; never a Forge framework table
forge db schema:dump # write db/schema.sql (pg_dump, mysqldump or sqlite3 on PATH)
forge db schema:load # load db/schema.sql into an existing, empty database (CI, tests)
forge db console # psql, mysql or sqlite3 on the application's database
forge db console -- -c 'select count(*) from forge_schema_migrations'
forge dev watches Go sources, templates, assets, migrations and
.env.development/.env.local. The old process keeps serving until the new
build succeeds, then gets SIGTERM and is replaced; build errors are printed
and shown over the page, and the running app is not stopped. With
FORGE_ENV=development (the default under forge dev) the forge process
listens on FORGE_HTTP_ADDR as a development-only proxy, runs the application
on a private loopback port and injects the reload script into HTML pages; the
page's Content-Security-Policy gets a per-response nonce only if it would block
that one script. Nothing of this is in your application binary
(ADR 0035, Database).
drop, reset, schema:load and rollback are refused unless the database is
local and FORGE_ENV is development or test: in production and staging, and
for any server that is not on this machine, you must pass --force-production
and either type the database name at the prompt or pass --yes with
FORGE_ALLOW_DESTRUCTIVE=true in the process environment:
FORGE_ALLOW_DESTRUCTIVE=true forge db drop --force-production --yes
FORGE_MIGRATION_DATABASE_URL names the migration role (on PostgreSQL, the
schema owner) for create, drop, schema:*, console, migrate and
rollback; the database itself always comes from FORGE_DATABASE_URL.
Sign in
Applications created with --auth (or --tenants) come with Rails-style
sign-in, sign-out, two-factor and password-reset pages, on every database:
Forge identity persists in PostgreSQL, MySQL, MariaDB and SQLite alike
(auth/postgres, auth/mysql, auth/sqlite, one shared contract suite).
End to end, from a new application to a signed-in owner using a scaffold:
forge new myapp --auth && cd myapp
forge migrate # identity tables and the application's migrations
forge setup --email you@example.com
# prompts twice for the owner password (12+ characters)
forge seed # optional: loads db/seeds.go (idempotent)
forge dev
Open http://localhost:8080/session/new and sign in with your email and
password (use localhost: the __Host- cookies need a secure context, which
browsers grant to http://localhost). An application with organizations
(--tenants) creates the first one with
forge setup --org acme --email you@example.com --org-name "Acme Inc", and
its sign-in page also asks for the organization. Then add pages and use them
as the owner:
forge generate scaffold post title:string body:text # --org-scoped with organizations; mounts itself in app/bootstrap/routes_gen.go
forge migrate # also grants posts:read and posts:write to the owner role
forge dev # http://localhost:8080/posts (signed-out visitors go to /session/new and back)
| URL |
Page |
/session/new |
sign in (email, password; and the organization, with --tenants) |
/session/mfa |
authenticator or backup code, when FORGE_AUTH_MFA_KEY is set |
/mfa |
two-factor settings of the signed-in account: turn on, replace the authenticator, new backup codes, turn off |
/passwords/new |
forgot password (only when reset emails are configured, below) |
/passwords/edit?token=... |
choose a new password from the emailed link |
Two-factor authentication: forge new puts a development
FORGE_AUTH_MFA_KEY in .env.local, so signed in, open
http://localhost:8080/mfa, enter your password, add the key to an
authenticator app (the otpauth:// link or the Base32 text) and confirm with
the password and its 6-digit code; the page shows 10 backup codes once and
signs you out everywhere. Every later change asks for the password and a
current code (or a backup code), throttled and audited. For production,
create a key with
openssl rand -base64 32 | tr '+/' '-_' | tr -d '=' and keep it in a secret
manager (docs/MFA.md, ADR 0045).
Release note: the MFA service and store contracts changed (a step-up proof
bound to the account is required by every change of an existing factor, and
a step-up backup code is used up with the change); custom stores and direct
callers port as docs/MFA.md describes.
Release note: pages now embed the CSRF token masked with a fresh pad on every
render (BREACH), so it no longer equals the __Host-forge_csrf cookie. Two
tests of applications generated earlier compare them byte for byte and fail:
TestLayoutRendersFlashAndCSRF (app/views/views_test.go) and
TestBrowserPipelineRequiresCSRFToken (app/bootstrap/bootstrap_test.go).
Replace the comparison with csrf.Match(cookie, embedded), as
docs/VIEWS.md
shows:
if !csrf.Match(cookies[0].Value, form.Body.String()) { // was: cookies[0].Value != form.Body.String()
t.Fatalf("form body %q does not carry the CSRF cookie's token", form.Body.String())
}
Password-reset emails are off until outgoing mail is configured, and so are
the reset pages and the "Forgot your password?" link. To try them locally,
add to .env.local (or uncomment the same lines in .env.development),
restart forge dev, request a reset at http://localhost:8080/passwords/new
and open the link in the newest tmp/mail/*.eml:
cat >> .env.local <<'ENV'
FORGE_MAIL_URL=file:tmp/mail
FORGE_MAIL_FROM="myapp <no-reply@example.com>"
FORGE_AUTH_PASSWORD_RESET_URL=http://localhost:8080/passwords/edit
ENV
Logins are ASCII email addresses (setup refuses others) and the reset page
answers at once, no sooner than 100 ms: the email is issued and sent a moment
later by a bounded queue the application owns. After upgrading an existing
application, run forge migrate (the login throttle gains a counter) and list
accounts whose stored login is not ASCII, which can no longer sign in
(docs/AUTHENTICATION.md):
forge migrate
psql "$FORGE_DATABASE_URL" -c "SELECT id, email FROM forge_users WHERE email_normalized !~ '^[!-~]+\$' OR email_normalized <> lower(email_normalized)"
Sessions end after two hours without a request (and at the latest 24 hours
after sign-in). Set another idle limit, from 5m to 720h, with
FORGE_AUTH_SESSION_IDLE_TIMEOUT. Existing applications run forge migrate
first: sessions gain their last use, and those open during the upgrade count
as used then (docs/AUTHENTICATION.md):
forge migrate
echo 'FORGE_AUTH_SESSION_IDLE_TIMEOUT=30m' >> .env.local
In production set FORGE_MAIL_URL=smtps://user:password@smtp.example.com:465
(or smtp://...:587 for STARTTLS), FORGE_MAIL_FROM and
FORGE_AUTH_PASSWORD_RESET_URL=https://app.example.com/passwords/edit; see
docs/MAIL.md.
Sign out with a DELETE /session form: the layout forge new --auth generates
shows it (the sessions/_sign_out partial) to signed-in users, with
{{if signedIn}}...{{end}}; in an application that added sign-in with
forge generate authentication, add that line to your layout. forge setup refuses once an
organization exists. The same commands exist in the application binary, for
containers and production (the password is never an argument; --org SLUG
only with organizations):
docker compose run --rm app setup --email you@example.com
printf '%s\n' "$OWNER_PASSWORD" | myapp setup --email you@example.com --password-stdin
printf '%s\n' "$OWNER_PASSWORD" | myapp setup --org acme --email you@example.com --password-stdin # --tenants
myapp seed
These pages are the only way to sign in to a new application. The JSON
authentication APIs (bearer POST /auth/login and /auth/logout, the cookie
login for browser scripts, MFA completion, JSON password recovery and
GET /v1/auth/session) are opt-in, since each is another place a password is
entered:
forge new myapi --api-auth # implies --auth: the pages plus the JSON authentication APIs
curl -s -X POST http://127.0.0.1:8080/auth/login -H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"your password"}' # with organizations, also "tenant":"acme"
An application created without the flag adds them later: in one that has
the pages, --api-auth writes nothing and prints only the JSON API wiring to
paste into identityRoutes:
forge generate authentication --api-auth # the pages exist: prints the JSON API wiring only
Applications generated earlier mount the JSON routes; remove those no API
client uses (forge generate authentication lists them,
docs/AUTHENTICATION.md).
An application created without --auth gets everything --auth generates,
wired with no edits (the pages, app/bootstrap/identity.go, the identity
migrations in db/migration_sets_gen.go and the setup command in
app/bootstrap/app_gen.go, the identityRoutes call in routes_gen.go, a
development MFA key in .env.local, and an AGENTS.md that describes
sign-in). Routes generated before keep the guard they were generated with:
forge generate authentication --dry-run # preview the files and what is wired
forge generate authentication # app/adapters/htmlauth, app/views/{sessions,passwords,mfa}, identity.go
forge migrate && forge setup --email you@example.com
forge generate authentication --api-auth # the same, and the wiring of the JSON authentication APIs
Passwords are stored as Argon2id (m=19456,t=2,p=1, the same hashes
Forge-Rust writes); PBKDF2 hashes from earlier releases are upgraded at the
next sign-in (until none remain, failed sign-ins wait as long as a PBKDF2
check, so dormant accounts cannot be told from unknown ones; the guide has
the SQL that resets dormant accounts), and at most one hash per CPU (16 at
most) runs at once, beyond which sign-ins answer 503 with Retry-After. An
application created with this release never stored a PBKDF2 hash and can
drop that wait where identityRoutes builds the login service:
hasher, err := auth.NewPasswordHasher(auth.WithLegacyHashTimingFloor(false))
login, err := auth.NewLoginService(repository, repository, append(loginOptions, auth.WithLoginPasswordHasher(hasher))...)
A FIPS 140 build writes PBKDF2-HMAC-SHA256 instead
(docs/AUTHENTICATION.md):
go build -tags forge_pbkdf2 -o bin/myapp ./cmd/myapp
Argon2id comes with the identity store the application links (auth/postgres,
auth/mysql or auth/sqlite register it). An application with its own
credential store registers it once, or it writes PBKDF2 and answers 503 for
a stored $argon2id$ hash:
import "golang.org/x/crypto/argon2"
func init() { auth.RegisterArgon2id(argon2.IDKey) }
Applications created with --api have no pages and are refused. See
docs/AUTHENTICATION.md.
Tables, pages and jobs
Adding a table:
forge generate migration create_notes --dry-run # preview exact migration files first
forge generate migration create_notes # edit db/migrations/<timestamp>_create_notes.{up,down}.sql
forge migrate
forge test
Generate a working HTML CRUD (index, show, new, edit, create, update and
destroy with forms, validation errors, flash notices and CSRF-protected forms)
together with the migration, domain, application, persistence and JSON API of
the resource:
forge generate scaffold post title:string body:text published:bool --global --dry-run
forge generate scaffold post title:string body:text published:bool --global
forge migrate
forge test
The command mounts the routes in app/bootstrap/routes_gen.go (below); the
pages are served at /posts and are never public (see
docs/VIEWS.md). In a PostgreSQL application a
signed-out browser visiting /posts is sent to the sign-in page and back; once
signed in as the owner, the pages work.
Generate a single server-rendered page (templates use Go's contextual HTML
escaping and are embedded into the application binary):
forge generate view welcome --dry-run # preview files without writing
forge generate view welcome
forge generate job cleanup --dry-run # preview job and test files
forge generate job cleanup
The generator creates app/views/pages/welcome.gohtml (or
app/views/DIR/NAME.gohtml for forge generate view DIR/NAME) and, for an
application that has no views package, the base forge new generates:
app/views/views.go, layouts/application.gohtml and shared/_flash.gohtml.
Build the renderer once during application composition with views.New(), then
call renderer.RenderRequest(w, r, "pages/welcome", http.StatusOK, data) from an
HTTP handler (data needs Title and Content). Execution errors happen before
response headers or body bytes are written.
Associations: belongs_to, has_many and many-to-many
A comment belongs to a post, a post has many comments, and posts and tags are
joined many to many (ADR 0051). From an empty
directory, with no code edited:
forge new blog --auth # also: forge new blog -d mysql --auth
cd blog
forge migrate
forge setup --email you@example.com
forge generate scaffold post title:string body:text
forge generate scaffold comment body:text post:references # belongs_to; a post has many comments
forge generate scaffold tag name:string
forge generate join post tag # many-to-many: post_tags
forge migrate && forge dev
With organizations (forge new blog --tenants, PostgreSQL), sign up the
organization and give each table its scope; the join takes its sides':
forge new blog --tenants && cd blog
docker compose up -d --wait postgres
forge migrate
forge setup --org acme --email you@example.com
forge generate scaffold post title:string body:text --org-scoped
forge generate scaffold comment body:text post:references --org-scoped
forge generate scaffold tag name:string --org-scoped
forge generate join post tag
forge migrate && forge dev
Sign in at http://localhost:8080/session/new, create a post, and its page
shows its comments and tags in Turbo Frames (/posts/{id}/comments,
/posts/{id}/tags), with a link to add a comment (the comment form picks
the post in a select) and forms to tag and untag it. As JSON:
curl -s -b "$jar" http://localhost:8080/v1/posts/$post/comments
curl -s -b "$jar" http://localhost:8080/v1/posts/$post/tags
curl -s -b "$jar" -H "X-CSRF-Token: $csrf" -H 'Content-Type: application/json' -d "{\"tag_id\":\"$tag\"}" http://localhost:8080/v1/posts/$post/tags
curl -s -b "$jar" -H "X-CSRF-Token: $csrf" -X DELETE http://localhost:8080/v1/posts/$post/tags/$tag
($jar and $csrf as in the sign-in example of the generated README.)
post:references (alias post:belongs_to, optional with post:references?)
is the column post_id with an index and a foreign key. With organizations
the key is composite, (org_id, post_id) to posts (org_id, id), so no row
can reference another organization's post even when the application's own
checks are bypassed; the first migration that needs it declares
UNIQUE (org_id, id) on posts. A single-organization application gets a
plain foreign key. The referenced resource must exist, with the same scope.
- The service checks the post before it writes: a post id that names no post
(or another organization's) is
422 comment_post_not_found, even when the
form's select is tampered with.
- Deleting a post that still has comments is refused:
409 post_in_use as
JSON, an alert on the post's page. Generate the child with
--on-delete=cascade to delete its rows with the parent instead
(forge generate scaffold note body:text post:references --on-delete=cascade).
forge generate join post tag creates post_tags (composite primary key,
foreign keys to both sides that cascade), attach, detach and list use cases,
GET|POST /v1/posts/{post_id}/tags, DELETE /v1/posts/{post_id}/tags/{tag_id}
and the HTML page the post shows, guarded by posts:read/posts:write and
tags:read. forge destroy join post tag removes it.
forge destroy refuses to remove a resource that something still
references; forge generate tenancy refuses an application with
associations (convert first, or create it with --tenants).
- Field types are
string text integer boolean timestamp uuid references,
also spelled int bool time datetime belongs_to.
Generators
Every generator refuses to overwrite a file, writes nothing when it refuses,
takes --dry-run, never edits app/bootstrap/bootstrap.go and never makes
anything public by accident. forge generate scaffold, resource and
controller mount their routes in app/bootstrap/routes_gen.go, a file
they own: bootstrap.go calls its generatedRoutes once, each generator adds
a block (and the guards, view renderer or live-update stream it needs, once),
forge destroy takes the block back out, and running a generator again with
the same arguments changes nothing. A hand edit of routes_gen.go is
detected by its checksum and refused, never overwritten; to replace a guard
(protect, protectCookie, protectPlatformWrite, public), declare a
function of that name in another file of app/bootstrap. Applications
generated before the seam (no generatedRoutes call in bootstrap.go) still
get the wiring printed to paste, and so do those created before forge new passed
the job registry to the seam (for generate job --queue). The mailer and job
generators wire themselves into the seam too: generate mailer writes the mail
sender and the previews, generate job --queue the handler's registration on
the queue registry, and destroy takes them out again. Fields are
name:type with type one of string, text, int, bool, time, uuid
(a trailing ? makes one optional; quote it in shells that expand ?). The
full reference is docs/GENERATORS.md.
In an application with organizations (forge new --tenants), tables take a
scope, --org-scoped (--tenant-scoped is an alias) or --global; in any
other application they are plain tables and take none, as below.
# g is short for generate and d for destroy, as in Rails
forge g scaffold product name:string price:integer
forge d scaffold product name:string price:integer
# Migration: add_COLUMNS_to_TABLE / remove_COLUMNS_from_TABLE with fields write the ALTER TABLE
forge g migration add_sku_to_products sku:string 'discontinued_at:time?'
forge g migration remove_sku_from_products sku:string
forge g migration backfill_prices # blank: fill in both files before forge migrate (an empty migration is refused)
# Model: migration, domain entity, repository port and use cases, persistence adapter, tests; no HTTP
forge generate model post title:string 'body:text?' published:bool --dry-run
forge generate model post title:string 'body:text?' published:bool
forge migrate && forge test
# Sign-in and organizations, added to an existing application
forge generate authentication # what forge new --auth generates, wired
forge generate tenancy --org acme # an --auth application gains organizations: org_id on every table
# Controller: handlers, routes, views and tests for read-only actions, no persistence
forge generate controller pages home about_us --public # GET /pages/home, GET /pages/about_us
forge generate controller reports monthly # guarded: needs the reports:read permission
forge generate controller status_checks index --api # JSON handlers under /v1
forge routes
# Mailer: typed methods, text and HTML templates, browser previews, tests
forge generate mailer user welcome:name,url password_reset:name,reset_url
FORGE_MAIL_URL=file:tmp/mails FORGE_MAIL_FROM='App <app@example.com>' FORGE_MAIL_PREVIEW=true forge dev
# http://localhost:8080/_mail/user/ (the generator already wired the previews)
# Job: a typed handler for the persistent queue with its enqueue function and tests
forge generate job send_welcome user_id:uuid --queue
forge generate job cleanup # the recurring in-process stub, unchanged
# Everything above, then the same arguments with destroy to take it away again
forge destroy controller pages home about_us --dry-run
forge destroy controller pages home about_us
forge destroy model post title:string 'body:text?' published:bool --migrations
forge destroy GENERATOR ARGUMENTS... (the arguments the generator was run
with) removes exactly the files the generator creates, and only while each is
unchanged: a file you edited is listed, nothing is removed, and --force
overrides. The same goes for files a bigger generation still needs: a model
inside a resource or scaffold, a view a controller renders. Migrations are kept unless --migrations (run forge rollback
first if they ran), shared files such as the views base are never removed, and
the routes, mailers and job registrations are taken out of
app/bootstrap/routes_gen.go (in an application without it, the wiring to take
out of bootstrap.go is printed).
forge new myapp generates:
myapp/
go.mod
cmd/myapp/main.go # forge.Main(bootstrap.Configure, <adapter> migrate/rollback, seed (and setup) commands)
app/bootstrap/bootstrap.go # opens the database, readiness check "database", GET /v1/status
app/bootstrap/bootstrap_test.go
app/bootstrap/routes_gen.go # the routes generate scaffold/resource/controller add (generator-owned)
app/architecture_test.go # enforces the ring dependency rules (see Architecture test)
AGENTS.md # guide for AI coding agents (see AGENTS.md for AI coding agents)
app/adapters/htmlauth/ # PostgreSQL: sign-in, sign-out, two-factor and password-reset pages
app/views/ # layout, flash partial (and, with PostgreSQL, sessions/ and passwords/)
db/db.go # embeds db/migrations into the binary
db/seeds.go # db.Seed: idempotent seed data for `seed`
db/migration_sets_gen.go # the migration sets migrate applies, the job queue's among them (generator-owned)
db/migrations/.keep
.env.development # committed development settings; no secrets
Dockerfile # multi-stage, distroless, nonroot
.dockerignore # excludes .git, .github, storage, all .env* files
compose.yaml # database server (if any), one-off migrate, app
compose.observability.yaml # opt-in overlay: collector, Prometheus, Tempo, Loki, Grafana
observability/ # their configuration, Grafana data sources and dashboard
.gitignore # ignores .env, .env.local, .env.*.local (and /storage/ for SQLite)
README.md
.github/workflows/ci.yml # gofmt, vet, test -race (with a database service), govulncheck, docker build
.github/dependabot.yml # weekly Go module, Actions, Docker and Compose updates
--skip-docker leaves out Dockerfile, .dockerignore, compose.yaml,
compose.observability.yaml, docker/ and observability/ (and CI's image
job, Dependabot's Docker updates and the release workflow); --skip-ci leaves
out .github/.
With --database none, db/, the database wiring, and the Compose and CI
database services are omitted. With SQLite, the image creates
/app/storage owned by the nonroot user and sets
FORGE_DATABASE_URL=sqlite:///app/storage/production.db; mount a persistent
volume there.
The generated Dockerfile builds a static binary (CGO_ENABLED=0, also for
SQLite) and runs it in gcr.io/distroless/static-debian12:nonroot,
configured for production behind a TLS-terminating proxy. See
docs/DEPLOYMENT.md and
docs/DATABASE.md.
Architecture test
Every generated application carries app/architecture_test.go, a test that
enforces the dependency direction of its rings with the standard library
(go/parser on the import lines, no tools to install). app/domain imports
only itself, the standard library without net, net/http, database/sql,
os, io/fs, io/ioutil, path/filepath, embed, html/template, unsafe
and cgo, and Forge's uuid and fault;
app/application imports app/domain, itself and Forge's uuid, fault,
pagination and tenancy; app/adapters and every other package outside
app/bootstrap never import app/bootstrap, the only composition root. Test
files are checked too: domain and application tests use in-memory fakes. It
passes on a fresh application and after every generator, and a violation
fails with the file, the line and the import:
forge new myapp -d sqlite3 && cd myapp
forge g scaffold product name:string --global
forge test ./app/ -run TestArchitecture -v # passes
printf 'package domain\n\nimport _ "net/http"\n' > app/domain/leak.go
forge test ./app/ -run TestArchitecture # app/domain/leak.go:3: imports "net/http": the domain ring must not do I/O or speak HTTP: ...
rm app/domain/leak.go
docs/ARCHITECTURE.md
has the rules; extraAllowedImports in the test admits a third-party package
to the inner rings on purpose.
AGENTS.md for AI coding agents
Every generated application carries AGENTS.md, a guide of under 120 lines
for AI coding agents (and new teammates): the rings and the architecture test,
the forge commands (generate, destroy, migrate, dev, test, check,
build), the routes seam (app/bootstrap/routes_gen.go is never edited by
hand), the security defaults (CSRF, tenant scoping, secrets never logged) and
how to run the tests. It describes only what that application has: an
--api application has no CSRF or view paragraph, one without a database has
no migration or tenant paragraph, and the sign-in paragraph appears only with
PostgreSQL. A framework test checks that every command and file it names
exists.
forge new myapp -d sqlite3 && cd myapp
head -20 AGENTS.md # what an agent reads first
printf '@AGENTS.md\n' > CLAUDE.md # optional: tools that read only CLAUDE.md import it
Translations (i18n)
Rails I18n with JSON files: every generated page reads its text from
config/locales (en.json, pt-BR.json, posts.en.json...), each request
gets a locale (the locale cookie, Accept-Language, then the default), and
forge i18n check lists what each locale lacks.
forge new shop --locale pt-BR # en.json + pt-BR.json; pt-BR is the default
cd shop && forge dev
curl -H 'Accept-Language: en' http://127.0.0.1:8080/ # Welcome to shop
curl -H 'Accept-Language: pt-BR' http://127.0.0.1:8080/ # Bem-vindo ao shop
forge generate scaffold post title:string --global # adds config/locales/posts.en.json and posts.pt-BR.json (English text)
forge i18n check # nothing missing: translate posts.pt-BR.json (values only; keep the "pt-BR" key)
FORGE_I18N_DEFAULT_LOCALE=en forge dev # change the default without a rebuild
To let the URL choose the locale, set PathPrefix where
useBrowserPipeline (app/bootstrap/bootstrap.go) builds the middleware:
translator.Middleware(i18n.NegotiationOptions{PathPrefix: true}).
curl http://127.0.0.1:8080/en/ # Welcome to shop: GET / in English, whatever Accept-Language says
curl http://127.0.0.1:8080/pt-BR/ # Bem-vindo ao shop
The locale segment is removed before any application middleware runs
(request IDs, telemetry and the panic log see the original path), so a path
guard sees /admin for /pt-BR/admin wherever it is registered (App.Use
or forge.WithHTTPMiddleware, before or after the i18n middleware).
In templates: {{t "posts.index.title"}}, {{t "users.inbox" "count" .Unread}}
(CLDR plural forms), {{l .CreatedAt "short"}}, {{number .Total 2}},
<html lang="{{locale}}">; in Go: i18n.T(r.Context(), "posts.flash.created").
Keys ending in _html are trusted markup with escaped arguments; everything
else is escaped by html/template. Missing translations show as
translation missing: LOCALE.KEY in development and test and fall back to the
default locale in production. See docs/I18N.md and
ADR 0042.
Credentials and attribute encryption
Rails' credentials:edit and Active Record Encryption, with the sharp edges
removed. forge new already created config/credentials/development.json.enc
(committed) and development.key (git-ignored, mode 0600).
forge credentials edit # edit development secrets in $EDITOR
forge credentials show # structure only; values redacted
forge credentials init --environment production # new key + encrypted file
forge credentials edit --environment production
forge credentials show --environment production --reveal # needs an interactive terminal
git add config/credentials/production.json.enc # the key file is ignored
# deploy: the server needs only the key
docker run --rm -p 8080:8080 -e FORGE_ENV=production \
-e FORGE_MASTER_KEY="$(cat config/credentials/production.key)" myapp
# rotate the key, keeping the old one readable while deployments catch up
forge credentials rotate-key --environment production --keep-old --print-key
Read secrets in the composition root, failing at startup if one is missing:
creds, err := credentials.Open(string(cfg.Environment), credentials.Options{
Required: []string{"stripe.api_key"}, // enforced outside development and test
})
stripeKey, err := creds.Secret("stripe", "api_key") // config.Secret: prints as [REDACTED]
Encrypt a column (the ciphertext is bound to table, column and row, so it cannot
be moved to another row; ssn is a TEXT column):
ring, _ := creds.KeyRing("encryption") // created by `init`
cipher, _ := encryption.NewCipher(ring)
ssn := encryption.NewValue[string](cipher, "people", "ssn").ForRecord(id.String())
ssn.Set("123-45-6789")
_, err = db.Exec(ctx, `INSERT INTO people (id, ssn) VALUES ($1, $2)`, id, ssn)
Key rotation, blind indexes for exact-match search and batch re-encryption are
in docs/ENCRYPTION.md; the credentials workflow, CI and
rotation are in docs/CREDENTIALS.md.
To rotate a data key, make the new key active, re-encrypt each column and
remove the old key only when a count of rows not on the active key is 0
(docs/ENCRYPTION.md). PostgreSQL tenant
tables (row-level security) rotate once per tenant; an unscoped run on them is
refused:
rewriter, err := postgres.EncryptedColumn(db, encryption.SQLColumn{Table: "people", Column: "ssn"},
postgres.WithTenant(tenant))
if err != nil {
return err
}
report, err := cipher.Reencrypt(ctx, rewriter, encryption.RotateOptions{})
-- as a role that sees every row (superuser or BYPASSRLS); stale must be 0
SELECT count(CASE WHEN ssn IS NOT NULL
AND substr(ssn, 1, length('fg1.2026-10.')) <> 'fg1.2026-10.' THEN 1 END) AS stale,
count(*) AS total
FROM people;
OpenAPI
JSON routes declare their request, response and error types next to the
route, and Forge builds an OpenAPI 3.1 document from the Go types: accurate
by construction for openapi.Typed handlers, checked by contract tests for
the rest. Generated applications, resources and --api controllers are
described out of the box, and so are the auth package's JSON routes
(/auth/login, logout, MFA completion, password recovery and the cookie
routes) in an application that registers them (forge new --api-auth).
forge new shop && cd shop && forge dev
curl http://127.0.0.1:8080/openapi.json # served in development and test
curl -s http://127.0.0.1:8080/openapi.json | grep '"/auth/' # --api-auth apps: the sign-in, recovery and cookie routes
forge openapi # print it (runs Configure, never listens)
forge openapi --output openapi.json # write it to commit and diff
forge openapi --check openapi.json # exit 1 when the committed file is stale
FORGE_OPENAPI_ENABLED=false forge dev # off; staging and production serve it only with =true
app.Handle("GET /v1/status", openapi.Typed(http.StatusOK,
openapi.Operation{Summary: "Service status", Tags: []string{"status"}},
func(*http.Request, openapi.NoBody) (statusResponse, error) { return statusResponse{Status: "ok"}, nil }))
app.Handle("POST /v1/posts", openapi.Describe(create, openapi.Operation{
Summary: "Create a post", Permission: "posts:write",
Request: openapi.TypeOf[createPost](),
Responses: []openapi.Response{openapi.JSON[post](http.StatusCreated)},
Errors: []int{http.StatusConflict},
}))
In tests, spec.CheckResponses(app.Handler(), report) validates every
response against the document. Details, type mapping and tags:
docs/OPENAPI.md, ADR 0043.
CLI
| Command |
Description |
forge new NAME [--database DB] [--auth] [--tenants] [--locale LOCALE] [--js] [--api-auth | --api] [--module PATH] [--dir DIR] [--forge-path DIR] [--skip-deps | --skip-lockfile] [--skip-database] [--skip-docker] [--skip-ci] |
Create an application. Refuses to write into a non-empty directory. --skip-lockfile is Forge-Rust's name for --skip-deps |
forge dev [NAME] [--no-reload] [--poll-interval D] [--debounce D] |
Build and run ./cmd/<name>; on a change to Go sources, templates, assets, migrations or .env.* files rebuild, restart gracefully and reload the browser (development only). A failed build leaves the running app alone. --no-reload builds and runs once |
forge test [ARGS] |
go test with the development environment loaded; ARGS default to ./... |
forge check |
Type-check the application: compile its TypeScript, then go build ./..., go vet ./... and, when tsc is on PATH, tsc --noEmit (without tsc one note says how to install it; Forge-Rust: cargo check --all-targets) |
forge format [--check] |
gofmt -l -w on the application's Go files (not vendor/, testdata/ or nested modules), listing the files it rewrote; --check lists the files that need formatting, writes nothing and exits 1 when there are any (CI) |
forge lint |
golangci-lint run ./... (with the application's .golangci.yml when it has one); fails with the install command when golangci-lint is not on PATH (Forge-Rust: cargo clippy -D warnings) |
forge build [NAME] |
go build -trimpath into bin/<name> |
forge db COMMAND |
Database lifecycle like Rails' db:*: create, drop, reset, setup, prepare, migrate, rollback, status, seed, schema:dump, schema:load, console; see Development loop |
forge migrate [status] |
Build the application and run its migrate (or migrate status) command (same as forge db migrate) |
forge rollback [-steps N] |
Build the application and run its rollback command (default 1 step): only the application's own migrations, never Forge's framework migrations |
forge setup [--org SLUG] --email EMAIL [--org-name NAME] [--password-stdin] |
Applications with Forge identity (--auth): create the first organization and owner account (--org only with organizations, --tenants; otherwise the one hidden organization); the password is prompted for (or piped, or FORGE_SETUP_PASSWORD), never an argument; refuses once an organization exists |
forge seed |
Run the application's seed command: db.Seed in one transaction |
forge generate migration NAME [--dry-run] |
Create or preview db/migrations/<UTC timestamp>_NAME.{up,down}.sql |
forge generate view [DIR/]NAME [--dry-run] |
Create or preview a server-rendered page, app/views/DIR/NAME.gohtml (DIR defaults to pages), in the layout forge new generates |
forge generate job NAME [--dry-run] |
Create or preview a cancellation-aware recurring job stub and tests; registration and schedule remain explicit |
forge generate model NAME field:type... [--org-scoped | --global] [--plural P] [--dry-run] |
Create or preview a model: migration, domain, application (repository port, use cases) and persistence layers with tests; the model and persistence files of generate resource, without the HTTP layer |
forge generate controller NAME ACTION... [--api] [--public] [--dry-run] |
Create or preview HTTP handlers for read-only actions, their routes, views (HTML) and tests, without persistence; --api generates JSON handlers; guarded unless --public |
forge generate mailer NAME METHOD[:field,field]... [--dry-run] |
Create or preview a mailer: typed methods, text and HTML templates, previews and tests, on the mail package |
forge generate job NAME [field:type...] --queue [--dry-run] |
Create or preview a typed handler for the persistent job queue, its enqueue function and tests |
forge generate resource NAME field:type... [--org-scoped | --global] [--plural P] [--public] [--dry-run] |
Create or preview a complete resource: migration, domain, application, persistence and HTTP layers with tests, for the application's database, mounted in app/bootstrap/routes_gen.go. A scope only with organizations. Never public unless --public (no Forge identity): see below |
forge generate scaffold NAME field:type... [--org-scoped | --global] [--plural P] [--broadcast] [--public] [--dry-run] |
Everything generate resource creates plus server-rendered HTML CRUD pages, forms and their tests; --broadcast adds live updates over Server-Sent Events. Never public unless --public: see below |
forge generate authentication [--api-auth] [--dry-run] |
Add what forge new --auth generates (sign-in, sign-out, two-factor and password-reset pages, identity migrations, the setup command) to an application created without it, wired with no edits; in one that has it, --api-auth prints the JSON authentication APIs' wiring |
forge generate tenancy [--org SLUG] [--dry-run] |
Turn an --auth application into one with organizations: a reversible migration adds org_id to every generated table (backfilled, indexed, NOT NULL, row-level security on PostgreSQL), rewrites the generated code as --org-scoped, and renames the default organization to SLUG; refuses edited files, applications without --auth and those that have organizations |
forge destroy GENERATOR ARGUMENTS... [--force] [--migrations] [--dry-run] |
Remove what a generator created (model, resource, scaffold, controller, mailer, job, view): the same arguments as the generate command; a file that differs from what the generator writes, or that a bigger generation still needs, is never removed without --force, migrations are kept without --migrations |
forge deploy [init|setup|rollback VERSION|prune|details|lock|maintenance] [--dry-run] [--json] |
Deploy the image to your servers over SSH with zero-downtime switches through forge-proxy, Kamal-style: see Deploy |
forge logs [-f] [--role R] [--host H] [--since 1h] [--grep REGEX] [--lines N] |
Stream container logs from every host, prefixed host| |
forge app exec [--role R] [--host H] [--interactive] [--reuse] CMD..., forge app shell [--image IMAGE] |
Run a command or a shell next to the deployed application |
forge credentials init | edit | show | rotate-key [--environment E] |
Encrypted credentials: create a key and skeleton, edit in $EDITOR (plaintext shredded on every exit path), show redacted (--reveal on a terminal only), rotate the key. See docs/CREDENTIALS.md |
forge i18n check [--json] |
Read-only: list the translations each locale in config/locales lacks (keys of the other locales and literal keys used by t and i18n.T), missing CLDR plural forms and unused keys; exit 1 when anything is missing. See Translations |
forge doctor [--json] |
Load and validate configuration and check that the database server answers (a TCP connection within 3 seconds, no login; SQLite: the file); exits 1 when anything is wrong. JSON mode emits versioned, secret-safe diagnostics with remediation codes |
forge inspect --json |
Read-only, versioned inventory of commands, migrations, static routes, modules and database adapter |
forge routes [NAME] |
Table of every route registered while the application composes (HTML and JSON, including the /v1/<plural> routes of generated resources) with its resource action and the source line that registered it, plus Forge's built-in routes. Builds cmd/NAME and runs its routes command without serving HTTP: Configure sees app.Serving() false, so a generated application opens its PostgreSQL or MySQL pool lazily (postgres.Lazy, mysql.Lazy) and starts no scheduler or worker, and the command works with the database stopped and records no job tick; NAME picks cmd/NAME when there are several binaries. When the application cannot be built or configured it says why on stderr and lists what a scan of the source finds (literal paths only). Try forge routes | grep /v1 |
forge openapi [--output FILE | --check FILE] |
Build the application and run its openapi command with the development environment: print or write the OpenAPI 3.1 document of the described routes, list undescribed ones on stderr; --check exits 1 when FILE is out of date. See OpenAPI |
forge help COMMAND, forge COMMAND --help (or -h) |
Print one command's usage, flags, examples and notes, and exit 0; for example forge help new or forge generate --help |
forge help --json |
Emit the versioned command catalog with per-command flags, examples, environment inputs, exit codes and side effects |
forge uuid [--count N] |
Print N UUIDv7 values (1-1000) |
forge version |
Print the CLI version, commit, commit time and Go toolchain, e.g. forge v0.1.0 (3f2a9c1d07be, 2026-10-27T12:00:00Z, go1.27.3 linux/amd64); forge-proxy version does the same |
Check, format and lint an application, from its directory (the counterparts of
Forge-Rust's forge check, forge format and forge lint):
forge check # go build ./..., go vet ./... and tsc --noEmit (if installed): type-check, no binary written
forge format --check # list the Go files that need gofmt and exit 1 if any; writes nothing (CI)
forge format # gofmt -l -w: rewrite them, listing each file
forge lint # golangci-lint run ./...; if it is missing, prints the go install command
NAME for dev and build is needed only if cmd/ has several commands.
migrate, rollback, setup, seed and db require exactly one.
Flags for forge new:
NAME must match ^[a-z][a-z0-9-]{0,62}$.
--module PATH sets the Go module path (default: NAME).
--dir DIR sets the output directory (default: NAME); it must be absent
or empty.
--skip-deps skips dependency resolution (go get, go mod tidy).
--database DB / -d DB chooses the database: sqlite (default; alias
sqlite3), postgresql (default with --tenants; aliases postgres,
pg), mysql, mariadb, or none.
--auth adds Forge identity: sign-in, sign-out, two-factor and
password-reset pages, the identity migrations and the setup command, on
any database. Without it the application has no sign-in.
--tenants (implies --auth) gives users organizations: sign-in and
setup take the organization, and generated tables are --org-scoped
(an org_id column) or --global; PostgreSQL adds forced row-level
security.
--skip-database is --database none; combining it with --database is
an error.
--locale LOCALE sets the default locale of the generated pages: en
(default), es or pt-BR. config/locales/en.json is always generated,
plus LOCALE.json.
--js keeps the front end in plain JavaScript (app/javascript/*.js,
served as written) instead of the default TypeScript; it cannot be combined
with --api (usage error, exit 2). See
TypeScript front end.
--api-auth also mounts the JSON authentication APIs (bearer and cookie
login and logout, MFA completion, JSON password recovery,
GET /v1/auth/session) beside the HTML sign-in pages; implies --auth.
Without it the pages are the only way to sign in.
--api generates an API-only application, like rails new --api and
Forge-Rust's forge new --api: GET / answers
{"application":"NAME","version":"0.1.0"}, GET /v1/status, the health
routes and the OpenAPI document stay, and there is no app/views,
app/assets, app/javascript, config/locales, app/adapters/htmlauth,
setup command or browser middleware (CSRF, method override, flash,
locale negotiation). Authentication is opt-in, so migrate applies the
application's own migrations (and the job schedules') and no identity
schema. The database flags are unchanged. --api-auth, --auth,
--tenants and --locale cannot be combined with it (usage error,
exit 2). In such an application forge generate view, scaffold,
authentication and controller without --api refuse and write
nothing; resource, model, controller --api, mailer, job and
migration work and write no view or asset.
--forge-path DIR is for framework contributors: it adds a replace
directive pointing at a local checkout, then runs go mod tidy and
go mod vendor so the Docker build context is self-contained. In such an
application (any with a vendor/ directory) every forge generate ... that
writes files ends with go mod vendor as well, so what it generated, which
may import framework packages vendor/ did not hold, still builds; a
--dry-run does not. Without it,
forge new runs go get github.com/rocketinsidecorp/forge-go@<cli version or latest> followed by go mod tidy. The CLI version is the release stamped at
build time or, for a CLI installed from the module proxy with
go install github.com/rocketinsidecorp/forge-go/cmd/forge@VERSION, the
module (pseudo-)version recorded in its build info (forge version reports
it too). A CLI built from a checkout (go build, or go install run in it)
resolves latest, even when Go stamps a VCS pseudo-version, because that
commit may not be published.
--framework-path DIR is the deprecated former name of --forge-path. It
still works for one more minor version and prints one deprecation line to
stderr; passing both flags with different directories is a usage error.
forge generate migration NAME requires NAME to match
^[a-z][a-z0-9_]{0,99}$ and db/migrations to exist. If a migration with the
same second already exists, the version is moved forward one second. It never
overwrites a file.
forge generate job NAME [--dry-run] creates app/jobs/NAME.go and its test
file. The generated stub returns an explicit not-implemented error until it
is replaced, so registering it cannot silently report successful work. It does
not edit bootstrap.JobsConfigure: choose a schedule and register the handler
there explicitly. NAME must be lowercase snake_case with each segment
starting with a letter. The generated shutdown hook calls
scheduler.StopContext(ctx), which cancels running jobs and waits for them only
within the shutdown budget (FORGE_HTTP_SHUTDOWN_TIMEOUT); a job that ignores
its context keeps running and the hook returns the deadline error. Scheduler
failures are reported but are not retried automatically. Every replica runs
the scheduler, and the generated JobsConfigure passes the database's tick
claimer (jobspostgres.NewTickClaimer, jobsmysql.NewTickClaimer) as
jobs.SchedulerOptions.Ticks: each tick of a job runs on exactly one replica,
the first to record it in forge_job_schedules (a framework migration that
the generated main.go composes, so migrate creates it). Recording a tick is
one statement; no connection is held while the job runs, and the outcome does
not depend on the replicas' clocks. A tick that cannot be recorded is skipped
and reported, and two replicas can run consecutive ticks of one job at the
same time. A job that falls behind its schedule (a run longer than its
interval, a paused VM, a slow tick claim, a wait for a free slot) does not
replay the ticks it missed back to back: only the latest tick due runs, once,
and only while it is at most jobs.SchedulerOptions.MaxLateness late (zero,
the default, means one interval of the job's schedule; an explicit value below
a minute is raised to a minute, as in Forge-Rust's Schedule::max_lateness);
the others are skipped
and reported to OnError as a *jobs.SkippedTicksError, which the generated
JobsConfigure logs as a warning. To skip a tick that is more than ten
minutes late, even for a daily job:
options := jobs.SchedulerOptions{
// ... MaxConcurrentJobs, Telemetry, OnError and Ticks as generated ...
MaxLateness: 10 * time.Minute,
}
An application generated earlier adopts it by composing the
migration in cmd/<name>/main.go and passing the claimer to JobsConfigure
in app/bootstrap/bootstrap.go. PostgreSQL:
// cmd/<name>/main.go: import jobspostgres "github.com/rocketinsidecorp/forge-go/jobs/postgres"
postgres.CommandsFromSets(authpostgres.Migrations(), jobspostgres.Migrations(), db.Migrations()),
// app/bootstrap/bootstrap.go, at the end of Configure (same import):
return JobsConfigure(app, jobspostgres.NewTickClaimer(database))
// JobsConfigure takes the claimer and sets it on the scheduler:
func JobsConfigure(app *forge.App, ticks jobs.TickClaimer) error {
options := jobs.SchedulerOptions{
// ... MaxConcurrentJobs, Telemetry and OnError as before ...
Ticks: ticks,
}
MySQL and MariaDB:
// cmd/<name>/main.go: import jobsmysql "github.com/rocketinsidecorp/forge-go/jobs/mysql"
forge.Main(bootstrap.Configure, append(mysql.CommandsFromSets(jobsmysql.Migrations(), db.Migrations()), mysql.SeedCommand(db.Seed))...)
// app/bootstrap/bootstrap.go, at the end of Configure (same import):
return JobsConfigure(app, jobsmysql.NewTickClaimer(database))
Then create the table and check the application:
forge migrate # creates forge_job_schedules once main.go composes the jobs migrations
forge test
forge generate resource NAME field:type... [--org-scoped | --global] [--plural PLURAL] [--public] [--dry-run] generates a working, tested CRUD resource for
the application's database (read from FORGE_DATABASE_URL, as forge inspect
does). NAME is singular snake_case; the table and routes use the plural
(category becomes categories; pass --plural for irregular words).
In an application with organizations exactly one scope flag is required; in
any other there is none. A field is name:type with type one of
string (up to 255 characters), text, int (64-bit; alias integer),
bool (alias boolean), time (UTC; aliases timestamp, datetime) or
uuid; a trailing ? makes it optional (NULL). Quote optional fields in
shells that expand ?. There is no floating-point or decimal type: store money
as an int in minor units.
forge generate resource product name:string 'summary:text?' price_cents:int --dry-run
forge generate resource product name:string 'summary:text?' price_cents:int # --org-scoped with organizations
forge migrate
It writes db/migrations/<timestamp>_create_products.{up,down}.sql and, for
each of app/domain, app/application, app/adapters/persistence and
app/adapters/httpapi, product.go with its product_test.go. Identifiers are
UUIDv7 (uuid on PostgreSQL, CHAR(36) on MySQL and MariaDB, TEXT in a
STRICT table on SQLite). Org-scoped resources read the organization from
the context, bind org_id in every statement and, on PostgreSQL, run behind
forced row-level security; MySQL, MariaDB and SQLite rely on the statement
predicates alone. Updates and deletes need the version the client read (409
when stale), lists are newest-first keyset pages, and PATCH changes only the
fields it names (null clears an optional one). PUT /v1/NAME/{id} is the
same handler under Rails' other name for update, and the OpenAPI document
describes both (updateNAME and replaceNAME):
curl -s -X PUT -H 'Content-Type: application/json' -d '{"version":1,"price":5900}' http://localhost:8080/v1/products/ID # as PATCH (add the CSRF header with sign-in)
The generator refuses to overwrite any file (running it again with the same
arguments is a no-op) and never edits app/bootstrap/bootstrap.go: it mounts
the routes in app/bootstrap/routes_gen.go. RegisterProducts needs a
protect function and asks it for products:read or products:write per
route, so the endpoints are never public by accident. With Forge identity
(--auth) the migration adds those permissions to forge_permissions, grants
them to the owner role, and protect uses authhttp.RequireAudited: behind the
bearer token of POST /auth/login in an application created with
--api-auth (or given it by forge generate authentication --api-auth), and
otherwise behind the session cookie of the sign-in page, with the CSRF token
required in the X-CSRF-Token header of every change (authhttp.RequireCSRF):
forge generate authentication --api-auth # paste the JSON authentication wiring it prints
The guard protect in routes_gen.go is written by the first generator that
needs it and kept as it is, so adding --api-auth later does not change the
routes already generated: once no route uses the cookie guard (destroy them),
the next generator writes the bearer one, or replace protect with your own
(copy it out of routes_gen.go into another file of app/bootstrap, then run
a generator again).
With organizations, a global resource's
write routes additionally stay behind a deny-all protectPlatformWrite you replace
with operator-level authorization (organization roles must not be able to change rows every
organization reads); without Forge identity the
guard denies every request until you replace protect with your own (copy
it out of routes_gen.go into another file of app/bootstrap, then run a
generator again) (or generate the routes with --public, which serves them
to anyone). The generated integration tests need the test
database (FORGE_TEST_DATABASE_URL, set by .env.development) and fail instead
of skipping when FORGE_TEST_REQUIRE_DATABASE=true.
forge generate scaffold NAME field:type... [--org-scoped | --global] [--plural PLURAL] [--broadcast] [--public] [--dry-run] is forge generate resource plus the HTML
pages, the equivalent of rails g scaffold. It takes exactly the same
arguments, writes exactly the same resource files, and adds:
app/adapters/htmlui/<name>.go and <name>_test.go: thin handlers for
index, new, create, show, edit, update and destroy, and
Register<Plural>(app, service, renderer, protect);
app/views/<plural>/{index,show,new,edit,_form,not_found}.gohtml;
- the views base (
app/views/views.go, layouts/application.gohtml,
shared/_flash.gohtml) when the application lacks it.
The pages live at /<plural> (the JSON API stays at /v1/<plural>, behind
the session cookie in an application without bearer sign-in, as above). Forms
carry the CSRF token and use _method for PATCH and DELETE; invalid input
re-renders the form with 422 and a message beside each field, success sets a
flash notice and answers 303 See Other, a missing record shows a 404 page,
and an edit based on a stale version shows the form again with 409 instead
of overwriting the other change. The pages use the same guard as the JSON
routes (<plural>:read for index and show, <plural>:write for forms and
changes); with Forge identity the printed wiring authenticates the session
cookie rather than a bearer token and sends signed-out browsers to the
sign-in page. On PostgreSQL the migration also grants the new permissions to
the owner role forge setup creates. --public (applications without
Forge identity, global resources only) serves the pages and the JSON API to
anyone instead. See docs/VIEWS.md.
TypeScript front end (the default)
forge new generates the front end in TypeScript, because Rails' Turbo setup
has no types (ADR 0049). There is
still no mandatory Node toolchain and no bundler: forge itself strips the
types with esbuild's Go API (one pure-Go module, linked into the forge
command only, never into your application), writes plain JavaScript to
app/javascript/dist, and the binary embeds and serves that with digested
URLs and the import map, as before. tsc only checks types, and only when
you have it.
forge new myapp -d sqlite3 && cd myapp # TypeScript by default
ls app/javascript app/javascript/dist # application.ts, controllers/*.ts -> dist/*.js
forge dev # recompiles on every .ts change, inline source maps
forge build # compiles to dist, then bin/myapp serves the precompiled JS (forge deploy compiles first too)
forge check # go build, go vet, and tsc --noEmit when tsc is installed
npm install --global --ignore-scripts typescript@6.0.3 # optional: the type checker (Node is needed for this only)
forge new myapp --js # opt out: plain JavaScript, exactly as before
What you get, beyond the sources: a strict tsconfig.json (noEmit, strict,
moduleResolution: "bundler", the import map's bare names as paths) and
app/javascript/types/, the declarations of Turbo and Stimulus copied
unchanged from npm (Stimulus 3.2.2's own, the exact version Forge serves;
Turbo 8.0.x's from DefinitelyTyped, because Turbo ships none). Without tsc,
forge check still compiles and says once how to install it. In production no
compiler runs: app/javascript/dist is generated, committed, embedded by
go build (so the Dockerfile and CI need nothing else), and a generated test
fails when a .ts file changed and dist did not, or when a compiled file is
not the one forge wrote (each header records the hashes of its source and of
its body; review dist/ diffs like code). In development forge dev and
forge dev --no-reload hand go build the recompiled files through
-overlay, so dist in your working tree stays the production output. Commands
that only run the application (forge migrate, seed, routes, openapi)
compile nothing and print nothing of their own. A .ts file outside
controllers/ (say lib/format.ts) is compiled and embedded; add
forgeassets.PinAllFrom(set, "lib") in app/assets/assets.go to import it by name.
Add a controller:
cp app/javascript/controllers/hello_controller.ts app/javascript/controllers/clipboard_controller.ts
# in app/javascript/controllers/index.ts:
# import ClipboardController from "controllers/clipboard_controller"
# application.register("clipboard", ClipboardController)
forge build
The generated CI installs the same pinned tsc (npm install --global --ignore-scripts typescript@6.0.3, the one npm package any workflow installs)
and runs tsc --noEmit. esbuild is pinned to 0.28.2 in go.mod; the update
policy and the alternatives that were rejected are in the ADR, the supply-chain
facts in docs/SUPPLY_CHAIN.md.
Hotwire: JavaScript, Turbo and live updates
Generated applications are full-stack like Rails 8, without Node. Static files
are embedded and served with digested, immutable URLs; Turbo and Stimulus are
vendored by Forge (pinned by SHA-256, licences retained) and loaded through an
import map that the strict Content-Security-Policy allows by hash. Forms and
links work under Turbo Drive with no changes.
forge new myapp && cd myapp
cat app/javascript/application.ts # import "@hotwired/turbo"; import "controllers"
cat app/javascript/controllers/hello_controller.ts # a Stimulus controller (TypeScript; --js for JavaScript)
curl -s localhost:8080/session/new | grep -E 'importmap|modulepreload|stylesheet' # with forge dev running (PostgreSQL)
# add a Stimulus controller
cp app/javascript/controllers/hello_controller.ts app/javascript/controllers/clipboard_controller.ts
# in app/javascript/controllers/index.ts:
# import ClipboardController from "controllers/clipboard_controller"
# application.register("clipboard", ClipboardController)
# in a template: <div data-controller="clipboard"> ... </div>
forge build # compile it (forge dev does on save)
Real-time updates: Turbo Streams pushed to every open page over
Server-Sent Events, with signed, tenant-scoped channel names, and PostgreSQL
LISTEN/NOTIFY to reach every replica:
export FORGE_STREAMS_KEY=$(openssl rand -base64 32) # required in staging and production
forge generate scaffold post title:string body:text --broadcast # --org-scoped with organizations
forge migrate
forge test # includes an end-to-end event stream test on PostgreSQL
Rotating the key without breaking open pages takes two redeploys, with the old
key kept for verification only (FORGE_STREAMS_PREVIOUS_KEYS, up to 3 keys,
comma-separated; why two steps:
docs/VIEWS.md).
OLD_STREAMS_KEY="$FORGE_STREAMS_KEY" # the key every replica uses now
NEW_STREAMS_KEY="$(openssl rand -base64 32)" # keep both somewhere safe
# step 1: every replica learns the new key, but still signs with the old one
export FORGE_STREAMS_KEY=$OLD_STREAMS_KEY
export FORGE_STREAMS_PREVIOUS_KEYS=$NEW_STREAMS_KEY
# redeploy every replica, then
# step 2: sign with the new key; the old one keeps verifying pages already open
export FORGE_STREAMS_KEY=$NEW_STREAMS_KEY
export FORGE_STREAMS_PREVIOUS_KEYS=$OLD_STREAMS_KEY
# redeploy every replica; after the token lifetime (24 hours by default)
# unset FORGE_STREAMS_PREVIOUS_KEYS and redeploy
Release note: the generated htmlstreams.NewSigner now takes the previous
keys as a third argument (FORGE_STREAMS_PREVIOUS_KEYS). streams.go is
written once, so an application generated earlier must update it before the
newly printed wiring compiles: see
docs/VIEWS.md.
Paste the printed wiring into app/bootstrap/bootstrap.go (it defines the
stream signer, mounts GET /streams behind the session cookie and the
posts:read permission, and passes the broker to the pages). Open /posts in
two browsers as users of the same organization: creating, editing or deleting a
post in one updates the other without a reload. In your own code:
channel, _ := broadcast.ForTenant(tenant.ID, "posts")
row, _ := renderer.Fragment(nil, "posts/_item", data) // escaped partial, no request-bound content
stream, _ := turbo.Prepend("posts", row)
_ = broadcast.Publish(ctx, broker, channel, stream) // and {{turboStreamFrom "posts"}} in the page
Details, the multi-replica setup and the delivery guarantees (best effort, at
most once) are in docs/VIEWS.md
and ADR 0029.
Deploy
forge deploy ships the application image to servers that have only SSH and
Docker (DEPLOYMENT.md,
ADR 0030). Build the proxy image once from
this repository and reference it by digest in proxy.image:
docker build -f cmd/forge-proxy/Dockerfile -t registry.example.com/acme/forge-proxy:0.1.0 .
docker push registry.example.com/acme/forge-proxy:0.1.0
Then, in the application:
forge deploy init # config/deploy.yml + .forge/secrets (0600, git-ignored)
$EDITOR config/deploy.yml .forge/secrets
forge deploy setup --dry-run # every remote command, secrets redacted
forge deploy setup # docker check, network, forge-proxy, accessories
forge deploy # build, push, migrate, health-gated switch on every host
forge deploy details # versions per host and role, proxy target, lock
forge logs -f --grep ERROR # host|line from every web host
forge app exec migrate status # one-off container of the deployed image
forge app shell --image busybox:1.37 # debug sidecar (distroless has no shell)
forge deploy rollback 3f2a9c1d07be # back to a retained version
forge deploy prune # drop stopped versions beyond retain
forge deploy lock acquire --message 'db maintenance' # hold the deploy lock (status | acquire | release [--force])
forge deploy maintenance on # forge-proxy serves its 503 page; `off` restores the application
forge deploy maintenance off
ssh deploy@host docker exec forge-proxy /usr/local/bin/forge-proxy status # forge-proxy: current targets per host
A deploy migrates before any switch, keeps the previous containers running
until every host passed its health gate, and routes all hosts back if one
fails. Exit codes: 2 invalid arguments (nothing ran), 3 lock held, 4
unhealthy (reverted), 5 migration failed, 6 hook failed, 7 not fully
reverted.
forge-proxy bounds what one client can make it hold: request headers up to
-max-header-bytes (64 KiB; 431 above it) and -max-connections open
connections per listener (10000). They are flags of forge-proxy run, with
those defaults in every deploy:
go run ./cmd/forge-proxy run -h # in a forge-go checkout: lists -max-header-bytes, -max-connections and the rest
On PostgreSQL with separate roles, the schema owner's credential goes to the
migrate step alone (migrate.env, see
Database roles):
$EDITOR config/deploy.yml .forge/secrets # uncomment migrate.env and the FORGE_MIGRATION_DATABASE_URL line
forge deploy --dry-run # prints the migrate env upload, run and rm commands; no value is printed, nothing runs
Release note: retain: 1 in config/deploy.yml is now refused (retain
counts the running version, so 1 would prune the previous one and leave
nothing to roll back to); use 2 to 100, default 5. The deploy lock also needs
~/.forge on the hosts to be on a file system with hard links.
Client addresses: behind forge-proxy the proxied role trusts X-Forwarded-For
only from the forge Docker network (forge deploy sets
FORGE_HTTP_TRUSTED_PROXIES to its subnets), so sign-in and reset throttles,
ratelimit.ByClientIP and audit count each client, not the proxy. Behind any
other proxy or load balancer, list its networks yourself (empty, the default,
trusts nobody; /0 and any IPv4 prefix shorter than /8 or IPv6 prefix
shorter than /16 are refused):
export FORGE_HTTP_TRUSTED_PROXIES=10.0.0.0/24,fd00:1::/64 # proxy CIDRs or addresses
# config/deploy.yml: override the automatic value ("" trusts nobody)
env:
clear:
FORGE_HTTP_TRUSTED_PROXIES: "172.18.0.0/16"
See ADR 0037.
Development environment files
forge dev, forge test, forge db, forge migrate, forge rollback, and
forge doctor read .env.development and then .env.local from the current
directory; a later file overrides an earlier one, and variables already set in
the process environment always win. dev, test, db, migrate, and rollback
set FORGE_ENV=development when no source sets it.
The format is deliberately small: KEY=VALUE lines, blank lines and lines
starting with # ignored, an optional export prefix, and one pair of
matching single or double quotes removed from the value. There is no variable
interpolation and no inline comment syntax. Parse errors name the line
number, never its content.
.env.development is committed and must not contain real secrets; put
personal overrides in .env.local, which is git-ignored.
forge doctor validates that configuration and checks that the database
server answers: a TCP connection to its host within 3 seconds, never a login
(SQLite: whether the file exists). It exits 1 when anything is wrong:
forge doctor # configuration: ok ... database reachability: the server accepts connections
forge doctor --json # {"ok":true,...}; database.unreachable when the server does not answer
Application binary commands
Binaries built with forge.Main accept:
| Command |
Description |
serve |
Default. Serve HTTP until SIGINT/SIGTERM |
healthcheck |
Probe /health/live on loopback; used by the Docker HEALTHCHECK |
openapi [--output FILE | --check FILE] |
Run Configure without listening and print or write the OpenAPI document (what forge openapi runs) |
routes |
Run Configure without listening and print the route table (what forge routes runs) |
While routes and openapi run Configure, app.Serving() is false. The
generated Configure then opens its database pool lazily and starts no
scheduler, job worker or recovery queue, so both work without a reachable
database and never run or claim a job. A hand-written Configure does the
same:
database, err := postgres.Open(ctx, app.Config().Database, postgres.Lazy(!app.Serving()))
// ...
if app.Serving() {
scheduler.Start() // and worker.Start(ctx), with their OnShutdown hooks
}
FORGE_DATABASE_URL=postgres://app:app@127.0.0.1:1/down forge routes # still lists every route
| help | List the available commands |
| migrate [status], rollback [-steps N] | Added by <adapter>.Commands (generated applications with a database); rollback reverts application migrations only |
| seed | Added by <adapter>.SeedCommand(db.Seed): the application's idempotent seed data in one transaction |
| setup [--org SLUG] --email EMAIL [--org-name NAME] [--password-stdin] | Added by auth<adapter>.SetupCommand(auth<adapter>.SetupOrganizations(Organizations)) in app/bootstrap/app_gen.go (applications with --auth): the first organization and owner, once |
| jobs status, jobs dead [-queue Q] [-kind K] [-limit N], jobs show ID, jobs retry ID, jobs discard ID, jobs prune [-older D] | Added by queuepostgres.Commands() (queuemysql, queuesqlite for the other databases) (forge new wires them in every application with a database; JOBS): inspect and repair the queue; payloads are never printed |
The jobs commands ship inside the production image, so operators need no
extra tooling:
docker compose run --rm app jobs status # jobs per queue and state
docker compose run --rm app jobs dead # dead jobs; also -queue Q, -kind K, -limit N
docker compose run --rm app jobs show JOB_ID # one job's metadata
docker compose run --rm app jobs retry JOB_ID # move a dead job back to pending
docker compose run --rm app jobs discard JOB_ID # delete a dead job permanently
docker compose run --rm app jobs prune -older 24h # delete finished jobs
Workers claim up to WorkerOptions.BatchSize jobs (default 10, never more
than their free slots) per round trip, and the outbox dispatcher claims
DispatcherOptions.BatchSize events (default 10) per transaction and
acknowledges each on its own. On PostgreSQL, queuepostgres.WithNotify()
wakes idle workers as soon as a job is enqueued (LISTEN/NOTIFY, with polling
as the fallback) instead of after up to 10 seconds of idle backoff:
store, err := queuepostgres.NewStore(database, queuepostgres.WithNotify())
worker, err := queue.NewWorker(store, registry, queue.WorkerOptions{BatchSize: 10})
dispatcher, err := outboxpostgres.NewDispatcher(database.Pool(), handler, outboxpostgres.DispatcherOptions{Workers: 2, BatchSize: 50})
FORGE_JOBS_NOTIFY=true|false switches the wake-up per deployment, with
Forge-Rust's meaning: unset keeps the code's choice, false turns it off even
where the code passes WithNotify() (cfg.Jobs.Notifies(codeChoice) decides; the
QueueConfigure that forge new generates asks it with false, so PostgreSQL
applications wake only when it is true):
FORGE_JOBS_NOTIFY=true forge dev # enqueues announce; workers LISTEN
FORGE_JOBS_NOTIFY=false ./bin/shop serve # off, whatever the code chose
docker compose run --rm app jobs status # ready, running and oldest-ready per queue: latency at a glance
Exit status is 0 on success, 1 on failure (including invalid
configuration), and 2 for an unknown command, an unknown or invalid flag, or invalid arguments (the same statuses as Forge-Rust).
For agent and script integrations, prefix any Forge CLI command with
--error-format=json to emit a versioned, secret-safe error object on stderr
when the command fails. Error messages intentionally omit underlying error
details and input values; doctor --json and inspect --json keep their
command-specific diagnostic reports on stdout.
Rate limiting, cache and file storage
Rails 8's rate_limit, Solid Cache and Active Storage, on the standard-library
core. Details: RATE_LIMITING, CACHING,
STORAGE.
Configure (all optional; invalid values stop the process at startup):
export FORGE_RATE_LIMIT_MAX_KEYS=100000 # in-memory limiter buckets
export FORGE_CACHE_MAX_ENTRIES=10000 FORGE_CACHE_DEFAULT_TTL=1h
export FORGE_STORAGE_ROOT=/var/lib/app/storage # enables disk storage
export FORGE_STORAGE_SIGNING_KEY=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=') # required in production
Rate limit one route (rate_limit to: 10, within: 3.minutes, only: :create),
per client address:
limiter, _ := ratelimit.NewMemory(ratelimit.MemoryOptionsFromConfig(app.Config().RateLimit))
create, err := ratelimit.Middleware(limiter, ratelimit.ByClientIP, ratelimit.Limit{N: 10, Per: 3 * time.Minute})
err = r.Handle("POST /comments", create(http.HandlerFunc(h.Create))) // 429 + Retry-After over the limit
Cache (memory, or shared in the database; stampede-protected Fetch):
c, _ := cache.NewMemory(cache.MemoryOptionsFromConfig(cfg.Cache))
price, err := cache.FetchJSON(ctx, c, cache.Key("price", sku), 10*time.Minute, loadPrice)
// A cached value that no longer decodes is a miss, logged once per key at
// warn level (key hashed) and counted:
undecodable := cache.DecodeFailures()
Shared stores for several replicas (PostgreSQL shown; mysql and sqlite work
the same). Compose their migrations, then run app migrate:
forge.Main(bootstrap.Configure, postgres.CommandsFromSets(db.Migrations(),
ratelimitpostgres.Migrations(), cachepostgres.Migrations())...)
limiter, _ := ratelimitpostgres.NewLimiter(db, ratelimit.StoreOptions{})
c, _ := cachepostgres.NewStore(db, cache.StoreOptions{})
app migrate # creates forge_rate_limits and forge_cache_entries
app rollback # reverts your newest migration, never these framework tables
app migrate status # a framework set you stopped composing shows as "disabled"
rollback refuses (exit status 1) when only framework migrations are left, and
removing a framework set from CommandsFromSets later keeps migrate working;
reverting a framework migration is the explicit Migrator.DownFramework API
(ADR 0039).
File storage: uploads with size and type limits, signed expiring links, safe
serving (only raster images inline, everything else a download):
store, _ := disk.NewFromConfig(cfg.Storage)
signer, _ := storage.NewSignerFromConfig(cfg.Storage)
files, _ := storage.NewHandler(store, signer, storage.HandlerOptions{})
_ = r.Handle("GET /files/{key...}", files)
got, err := storage.Receive(w, r, store, storage.UploadOptions{
Field: "avatar", AllowedTypes: []string{"image/png", "image/jpeg"},
MaxFileBytes: cfg.Storage.EffectiveMaxUploadBytes(), Prefix: "avatars"})
url, _ := signer.URL("/files", got.Files[0].Key, storage.SignOptions{Inline: true})
There is no S3 adapter yet (see the roadmap). The upload limit
cannot exceed FORGE_HTTP_MAX_BODY_BYTES.
Observability
Development log
forge dev writes a Rails-style log: one line per request (method, route
pattern, status, time, number of SQL queries, request ID) and per SQL statement
(text and time, never the arguments; yellow when slow), in color on a
terminal. Job lines show failures and retries always; successes only when the
worker is built with LogSuccesses (below). Production keeps JSON with the
same event names (duration is in nanoseconds there).
forge dev # pretty on a terminal: nothing to set
FORGE_LOG_FORMAT=pretty forge dev # the same layout on a pipe or a file
NO_COLOR=1 forge dev # the layout without ANSI colors
FORGE_LOG_SLOW_QUERY=50ms forge dev # statements slower than 50ms turn yellow (default 100ms)
FORGE_LOG_FORMAT=json forge dev # text, json or auto (the default) also work
FORGE_LOG_LEVEL=debug ./your-app # the request, SQL and job lines are Debug outside development
FORGE_LOG_REDACT_KEYS=iban,card forge dev # more keys to hide, on top of the defaults
01:42:35.143 HTTP GET / 200 470µs queries=0 req=01a17b06-0787-7333-a91f-9b6aeeb55a45
SQL 182µs SELECT "id", "name", "price" FROM "products" ORDER BY "id" DESC LIMIT ?
01:42:35.153 HTTP GET /products 200 566µs queries=1 req=01a17b06-0790-727b-8ebe-36a51ae90690
01:42:35.172 HTTP GET - 404 91µs queries=0 req=01a17b06-07a4-71ee-9912-e2e721cf4158
01:42:35.200 JOB send_welcome succeeded 35ms queue=default attempt=1
The value of every log attribute whose key contains password, passwd,
secret, token, authorization, cookie, api_key, apikey, session,
email, phone, cpf or cnpj (and api-key, access_key, private_key,
credential, csrf, jwt, otp, dsn, database_url) is written as
[REDACTED] in every format. Redaction is by key only: messages and values
(structs, headers) are not inspected, so log identifiers, not objects.
A worker you build logs its successes at Info in development with
queue.WorkerOptions{LogSuccesses: cfg.EventLevel() == slog.LevelInfo}. The
layout, the variables, the redaction rules and the JSON events:
docs/OBSERVABILITY.md.
Telemetry
Telemetry is on by default and exports nothing: logs carry trace_id, and
incoming traceparent headers are ignored unless you opt in with
FORGE_TELEMETRY_TRUST_TRACEPARENT=true behind a proxy that sanitizes them. Pick an export path with
environment variables:
# Prometheus metrics on a separate, private listener
FORGE_TELEMETRY_METRICS_ADDR=127.0.0.1:9464 forge dev
curl -s http://127.0.0.1:9464/metrics | grep http_server_request_duration_seconds
# Traces and metrics to an OpenTelemetry Collector over OTLP/HTTP
docker run --rm -p 4318:4318 -v "$PWD/otel-collector.yaml:/etc/otelcol/config.yaml:ro" otel/opentelemetry-collector:0.111.0
FORGE_TELEMETRY_EXPORTER=otlp FORGE_TELEMETRY_OTLP_ENDPOINT=http://127.0.0.1:4318 \
FORGE_TELEMETRY_SERVICE_NAME=myapp forge dev
# Logs too: every record, with trace_id and span_id, also goes to the collector
# (stdout keeps the same lines; same FORGE_TELEMETRY_OTLP_* settings)
FORGE_TELEMETRY_LOGS_EXPORTER=otlp FORGE_TELEMETRY_OTLP_ENDPOINT=http://127.0.0.1:4318 forge dev
# In a container: bind to all interfaces and confirm it (never publish the port)
FORGE_TELEMETRY_METRICS_ADDR=0.0.0.0:9464 FORGE_TELEMETRY_METRICS_PUBLIC=true forge dev
# Spans in the development log, or no telemetry at all
FORGE_TELEMETRY_EXPORTER=log forge dev
FORGE_TELEMETRY_ENABLED=false forge dev
If your standard-output logger redacts with slog.HandlerOptions.ReplaceAttr,
build it with telemetry.NewOutputHandler so the log exporter applies the same
redaction (otherwise the collector receives what stdout hides):
logger := slog.New(telemetry.NewOutputHandler(os.Stdout, telemetry.LogFormatJSON,
&slog.HandlerOptions{ReplaceAttr: redact}))
app, err := forge.New(forge.WithLogger(logger))
Pass app.Telemetry() to adapters to trace database statements and jobs and to
export their connection pool (in use, idle, limit, waits):
postgres.Open(ctx, cfg, postgres.WithTelemetry(app.Telemetry())) (generated
applications already do). Go runtime metrics (goroutines, heap, GC) are always
recorded. Collector and Prometheus configuration, the
metric catalogue, cardinality rules and what is never recorded:
docs/OBSERVABILITY.md.
Every generated application includes an opt-in observability stack
(compose.observability.yaml and observability/): an OpenTelemetry
Collector, Prometheus, Tempo (traces), Loki (logs) and Grafana with the data
sources, trace-to-log links and a starter dashboard (requests, database and
connection pool, Go runtime, jobs, logs, traces) provisioned; the app's logs
reach Loki over OTLP. Everything
stays on your machine and every port is bound to 127.0.0.1
(ADR 0038).
forge new myapp && cd myapp
export GRAFANA_ADMIN_PASSWORD="$(openssl rand -base64 18)" # required; or put it in .env
docker compose -f compose.yaml -f compose.observability.yaml up --build
curl http://127.0.0.1:8080/v1/status
# Grafana http://127.0.0.1:3000 (user admin): dashboard "Forge application",
# Explore > Tempo (traces), Explore > Loki (logs); Prometheus http://127.0.0.1:9090
# Alert rules (observability/alerts.yml): http://127.0.0.1:9090/alerts
# compose interpolates the whole file, so GRAFANA_ADMIN_PASSWORD must be set for down too
docker compose -f compose.yaml -f compose.observability.yaml down # add -v to delete the data
Prometheus also loads observability/alerts.yml: scrape target down or no
metrics arriving, more than 5% of requests failing with 5xx, p95 latency over
500 ms, database pool over 90% in use or callers waiting for a connection,
queue jobs that died, scheduled jobs that failed. Open
http://127.0.0.1:9090/alerts to see which fire; edit the thresholds in the file
and docker compose -f compose.yaml -f compose.observability.yaml restart prometheus.
The stack has no Alertmanager, and a framework test fails if a rule uses a
metric or label Forge does not export
(the rules).
The default docker compose up does not start it. See
docs/OBSERVABILITY.md
for how logs reach Loki and what the dashboard shows.
Supply chain: fuzzing, SBOM, signed images and binaries
Every fuzz target runs on each pull request and nightly for longer; SBOMs
describe the binaries and images; release images and the release checksums
are signed keyless with Sigstore cosign from v* tags only, and every release
archive has a GitHub build provenance attestation (Install shows
the commands); actions and images are pinned by SHA and digest (docs/SUPPLY_CHAIN.md,
ADR 0044).
# fuzz locally: every target 10s (what CI runs), one package longer, one target
scripts/fuzz.sh
scripts/fuzz.sh -t 2m ./i18n
go test -run '^$' -fuzz '^FuzzDatabaseURL$' -fuzztime 1m ./config
scripts/fuzz.sh -l # list the targets
# a failing input lands in <package>/testdata/fuzz/<target>/: fix the bug, commit the input
# verify a released image (cosign v3+), then deploy it by digest
cosign verify ghcr.io/rocketinsidecorp/forge-proxy:v0.1.0 \
--certificate-identity-regexp '^https://github\.com/rocketinsidecorp/forge-go/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
docker buildx imagetools inspect ghcr.io/rocketinsidecorp/forge-proxy:v0.1.0 --format '{{ .Manifest.Digest }}'
# read the SBOMs: an image's (SPDX attestation), the binaries' (CycloneDX)
docker buildx imagetools inspect ghcr.io/rocketinsidecorp/forge-proxy:v0.1.0 \
--format '{{ json (index .SBOM "linux/amd64").SPDX }}' | jq -r '.packages[] | "\(.name) \(.versionInfo)"'
scripts/sbom.sh dist/sbom # builds forge and forge-proxy; Syft runs in Docker
jq -r '.components[] | "\(.name) \(.version)"' dist/sbom/forge.cdx.json
Applications from forge new keep an SBOM of their image as the sbom
artifact of every CI run, and get an opt-in .github/workflows/release.yml
(enable it with gh variable set FORGE_RELEASE_IMAGES --body true, then push
a v* tag); their README shows how to verify the result. Their Dockerfile
pins both base images by digest, the same ones Forge builds with
(TestDockerfilesPinBaseImagesByDigest), and Dependabot proposes new
digests. To move to another Go release, replace the whole reference:
docker buildx imagetools inspect golang:1.27.4-alpine --format '{{ .Manifest.Digest }}'
# then in Dockerfile: FROM --platform=$BUILDPLATFORM golang:1.27.4-alpine@sha256:<that digest> AS build
Benchmarks
Micro-benchmarks of the hot paths (router, middleware chain, JSON and HTML
rendering, CSRF, session cookie, password hashing, UUIDv7, i18n, attribute
encryption, rate limiting) and an end-to-end HTTP benchmark of a generated
application on PostgreSQL, with a load generator written with the standard
library only. Methodology, scenarios, caveats and a dated results table:
docs/BENCHMARKS.md.
# micro-benchmarks, one package at a time so they do not compete for CPU
go test -p 1 -run '^$' -bench . -benchmem -count 3 ./...
go test -run '^$' -bench 'RouterMatch|MiddlewareChain' -benchmem ./router . # a subset
# end to end: builds a generated app against this checkout, runs it on PostgreSQL,
# prints requests/s and p50/p95/p99 of /health, welcome, index, JSON show, create
export FORGE_TEST_POSTGRES_URL='postgres://postgres:postgres@127.0.0.1:5432/postgres?sslmode=disable'
scripts/bench.sh # 16 workers, 10s per scenario, 3 runs
BENCH_DURATION=3s BENCH_RUNS=1 scripts/bench.sh # a quick check
scripts/bench.sh -format json # the same figures as JSON
CI runs every benchmark once (go test -run '^$' -bench . -benchtime=1x ./...)
so they keep compiling and running; it never compares numbers.
Example
examples/basic-api is the reference service: a task
API on PostgreSQL laid out in hexagonal layers (app/domain,
app/application, app/adapters/persistence, app/adapters/httpapi,
app/bootstrap) with SQL migrations in db/migrations. It demonstrates
repositories on DBTX, a transaction, optimistic locking with a version
column, keyset pagination, and the JSON error format. Endpoints:
POST /v1/tasks, GET /v1/tasks, GET /v1/tasks/{id}, and
POST /v1/tasks/{id}/complete.
The repository's compose.yaml runs it with PostgreSQL:
docker compose up --build
curl -i -X POST -H 'Content-Type: application/json' -d '{"title":"try forge"}' \
http://127.0.0.1:8080/v1/tasks
Developing the framework
go vet ./...
golangci-lint run
go test -race ./...
govulncheck ./...
SQLite tests always run. PostgreSQL and MySQL/MariaDB tests are skipped
unless a server is configured whose user can create databases:
export FORGE_TEST_POSTGRES_URL='postgres://forge:forge@127.0.0.1:5432/postgres?sslmode=disable'
export FORGE_TEST_MYSQL_URL='mysql://root:forge@127.0.0.1:3306/mysql' # MySQL or MariaDB
Set FORGE_TEST_REQUIRE_DATABASE=true to make a missing server a failure.
Coverage: scripts/coverage.sh measures statement coverage (go test -coverpkg=./..., with the database tests when the servers above are set),
prints the gross total and the gated total (production code, without the
contract test kits) with a per-package table, and fails when the gated total is
below .coverage-floor, the gross total is below 95.0%, or one of the
security-relevant packages (auth, auth/postgres, auth/mysql,
auth/sqlite, auth/authsql, credentials, csrf,
encryption, postgres/internal/pgurl, postgres) is below 95% of its own statements
(docs/TESTING.md).
CI (.github/workflows/ci.yml):
quality: gofmt, vet, golangci-lint, race tests against PostgreSQL 18
and MySQL 8.4 services and a real sshd (every test run with an explicit
-timeout 30m, TestGeneratedApplication skipped), benchmarks, and
govulncheck;
generated: TestGeneratedApplication, the applications forge new
generates for each database, vetted, built and tested against PostgreSQL 18
and MySQL 8.4, then the README quickstarts (SQLite --auth, MySQL --auth,
PostgreSQL --tenants, the welcome page) driven over HTTP (a job of its
own: these workflows are serial and slow);
coverage: scripts/coverage.sh against PostgreSQL 18 and 16 and MariaDB,
gated by .coverage-floor, the gross floor and the per-package floors;
fuzz: every fuzz target for 10 seconds (scripts/fuzz.sh);
sbom: CycloneDX SBOMs of the forge and forge-proxy binaries;
mariadb: the mysql and auth/mysql tests, the generated MySQL
applications and the MySQL quickstart against MariaDB 11.8;
docker: for each of PostgreSQL, MySQL, MariaDB, and SQLite, generates an
application, builds its image, and smoke-tests its Compose stack with
docker compose up --wait (the PostgreSQL run also builds the example
image and an SBOM of the generated one).
Other workflows: fuzz.yml fuzzes each target for 10 minutes every night,
scorecard.yml runs OpenSSF Scorecard weekly, and release.yml publishes
signed images for v* tags only.
Documentation
- Getting started
- Architecture
- Authentication and authorization
- AI agent guide and current authorization boundaries
- Database
- Server-rendered HTML
- Generators and
forge destroy
- Internationalization (i18n)
- OpenAPI from typed routes
- Rate limiting, caching and file storage
- Deployment
- Observability
- Credentials and attribute encryption
- Roadmap
- Rails parity (Forge-Go and Forge-Rust)
- Threat model
- Supply chain: fuzzing, SBOM, signed images and binaries
- Releasing (maintainers)
- Testing and coverage
- Benchmarks
- Multi-tenancy
- Security policy
- Architecture decision records: docs/adr
- 0001 UUIDv7 as the default identifier
- 0002 Standard library
net/http core
- 0003 Docker by default
- 0004 Hexagonal application layout
- 0005 Environment configuration, fail fast
- 0006 Zero-dependency core
- 0007 PostgreSQL adapter with pgx, in the main module
- 0008 SQL migrations embedded in the application binary
- 0009 Multiple databases: PostgreSQL, MySQL, MariaDB and SQLite
- 0030
forge deploy and forge-proxy
- 0035 Development loop and database lifecycle
- 0031 Rate limiting
- 0032 Application cache
- 0033 File storage
- 0036 Encrypted credentials and attribute encryption
- 0037 Trusted proxies and the client address
- 0038 Local observability stack
- 0039 Framework migration ownership in the application's migration table
- 0040 OTLP log export, Go runtime and connection pool metrics
- 0041 Model, controller, mailer and queue job generators, and
forge destroy
- 0042 Internationalization (i18n)
- 0043 OpenAPI from typed routes
- 0044 Continuous fuzzing, SBOMs and signed images
- 0045 Generated two-factor settings pages with step-up
- 0047 Batch claims, LISTEN/NOTIFY wake-up and recorded scheduler ticks