Configuration#
This page covers how a Spider-Gazelle app is configured: environment variables,
src/config.cr and the command line options of the compiled binary. It follows the
layout of the application template,
which most apps start from.
Quick example#
Every setting has a default, so the app runs without any configuration. In production, set the environment and a session secret, and bind to all interfaces:
export SG_ENV=production
export COOKIE_SESSION_SECRET="$(openssl rand -hex 16)"
./bin/app -b 0.0.0.0 -p 8080 -w 4
How the template is organised#
The template splits configuration over three files. Each has one job:
| File | Job |
|---|---|
src/constants.cr |
Reads environment variables into constants such as App::DEFAULT_PORT. Has no side effects. |
src/app.cr |
Parses the command line, then requires config.cr and starts the server. |
src/config.cr |
Requires your controllers and models, then configures logging, middleware, the MCP server and sessions. |
app.cr parses the command line before it requires config.cr. This means
options such as --routes and --help exit before any database connections
or other start-up work in config.cr happens. Specs require config.cr directly,
so your tests get the same configuration without starting a server.
Environment variables#
These are read in src/constants.cr. Command line options take precedence over
the port, host and worker count.
| Variable | Default | Purpose |
|---|---|---|
SG_ENV |
development |
Set to production for production behaviour, see below. |
SG_SERVER_HOST |
127.0.0.1 |
Address to bind. Use 0.0.0.0 in containers. |
SG_SERVER_PORT |
3000 |
Port to bind. |
SG_WORKER_COUNT |
1 |
Number of threads handling requests, see Workers and threads. |
PUBLIC_WWW_PATH |
./www |
Folder of static files. Ignored if the folder doesn't exist. |
COOKIE_SESSION_KEY |
_spider_gazelle_ |
Name of the session cookie. |
COOKIE_SESSION_SECRET |
a fixed example value | Secret that encrypts and signs the session cookie. Must be at least 32 bytes. |
SG_MCP_PATH |
/mcp |
Path of the MCP endpoint. An empty string disables it. |
SG_MCP_DESCRIPTION |
mcp.yml |
Location of the generated MCP tool descriptions. |
Warning
The default COOKIE_SESSION_SECRET is in the public template, so anyone can
forge your session cookies with it. Always set your own secret in production.
Production mode#
When SG_ENV=production, App.running_in_production? returns true and the
template:
- logs at
infofor your app and action-controller, andwarnfor everything else (instead ofdebugandinfo), see Logging - uses the production error handler, which doesn't send exception details or backtraces to the client
- marks the session cookie
Secure, so browsers only send it over HTTPS
Use App.running_in_production? in your own code for anything else that differs
between environments.
config.cr#
This is the template's src/config.cr, section by section.
Requires#
require "action-controller"
require "./constants"
require "./controllers/application"
require "./controllers/*"
require "./models/*"
# Server required after application controllers
require "action-controller/server"
# Exposes the application routes to LLM clients
require "action-controller/mcp"
action-controller/server must be required after your controllers. Routes are
collected at compile time, and the server only sees controllers defined before it
is required.
Logging#
if running_in_production?
log_level = ::Log::Severity::Info
::Log.setup "*", :warn, LOG_BACKEND
else
log_level = ::Log::Severity::Debug
::Log.setup "*", :info, LOG_BACKEND
end
::Log.builder.bind "action-controller.*", log_level, LOG_BACKEND
::Log.builder.bind "#{NAME}.*", log_level, LOG_BACKEND
See Logging for log sources, request IDs, JSON output and changing the level at runtime.
Middleware#
filter_params = ["password", "bearer_token"]
keeps_headers = ["X-Request-ID"]
ActionController::Server.before(
ActionController::ErrorHandler.new(running_in_production?, keeps_headers),
ActionController::LogHandler.new(filter_params),
HTTP::CompressHandler.new
)
ActionController::Server.before adds standard Crystal
HTTP handlers that run, in
order, before your routes. Server.after adds handlers that only run when no route
matches. Handlers must be added before the server is created.
ErrorHandler.new(production, persist_headers)turns unhandled exceptions into500responses. In development it renders a detailed exception page. Headers named inpersist_headersare kept on the error response, so clients still get theirX-Request-ID.LogHandler.new(filter)logs every response. Query string params named infilterare logged as[FILTERED].HTTP::CompressHandlergzips or deflates responses when the client supports it.
Static files#
if File.directory?(STATIC_FILE_PATH)
::MIME.register(".yaml", "text/yaml")
ActionController::Server.before(
::HTTP::StaticFileHandler.new(STATIC_FILE_PATH, directory_listing: false)
)
end
Files in ./www (or PUBLIC_WWW_PATH) are served when a request doesn't match a
route.
MCP server#
ActionController::MCPServer.tap do |mcp|
mcp.server_name = NAME
mcp.server_version = VERSION
mcp.description_path = ENV["SG_MCP_DESCRIPTION"]? || "mcp.yml"
end
app.cr mounts the MCP server at SG_MCP_PATH. The options, including
authentication, are covered in the MCP guide.
Sessions#
ActionController::Session.configure do |settings|
settings.key = COOKIE_SESSION_KEY
settings.secret = COOKIE_SESSION_SECRET
# HTTPS only:
settings.secure = running_in_production?
end
key and secret have no defaults in action-controller, so they must be set
before a session is used. See Sessions and cookies for
every option.
Command line options#
These are defined in the template's src/app.cr. Run ./bin/app --help to list
them.
| Option | Description |
|---|---|
-b HOST, --bind=HOST |
Address to bind. Default SG_SERVER_HOST, else 127.0.0.1. |
-p PORT, --port=PORT |
Port to bind. Default SG_SERVER_PORT, else 3000. |
-w COUNT, --workers=COUNT |
Number of threads handling requests. Default SG_WORKER_COUNT, else 1. 0 or less uses the CPU count. |
-r, --routes |
Prints the routes, then exits. |
-v, --version |
Prints the app name and version, then exits. |
-c URL, --curl=URL |
Requests URL as a health check, then exits. See below. |
-d, --docs |
Prints the OpenAPI document as YAML, then exits. |
-f FILE, --file=FILE |
With --docs, writes the document to FILE instead. Must come after --docs. |
--mcp=FILE |
Writes the MCP tool descriptions to FILE, then exits. |
-h, --help |
Prints the options, then exits. |
Listing routes#
$ ./bin/app --routes
Controller#Action Verb URI Pattern
App::Welcome#index get /
App::Welcome#api get /api/:example
App::Welcome#api get /api/other/route
App::Welcome#api post /api/:example
App::Welcome#openapi get /openapi
Generating OpenAPI and MCP descriptions#
./bin/app --docs --file=openapi.yml
./bin/app --mcp=mcp.yml
Both read the doc comments in your source code by running crystal docs. So
they must be run from the project root, on a machine with crystal installed.
That's why the template's Dockerfile generates both
files during the build stage and copies them into the final image.
Note
--file is only registered once --docs has been parsed. --docs --file=x.yml
works, --file=x.yml --docs doesn't. It's also why --file isn't listed by
--help.
Health checks#
./bin/app -c http://127.0.0.1:3000/
-c requests the URL using Crystal's built-in HTTP client, so you don't need
curl in your container image. It exits with:
| Exit code | Meaning |
|---|---|
0 |
The response status was between 200 and 499. |
1 |
Any other status, for example a 5xx. |
2 |
The request failed, for example the connection was refused. |
A 4xx counts as healthy because the server answered. Point it at a route that doesn't need authentication. See Deployment.
Workers and threads#
-w (or SG_WORKER_COUNT) scales your app across CPU cores with threads. A single
process runs your requests on several threads:
server.threads(thread_count)
threads resizes Crystal's default execution context to count threads. 0 or less
uses the CPU count, and 1 (the default) keeps the app single threaded.
Threads share memory: a cache, or the in-memory sessions of the MCP server, is shared by
every request. Any state you change from requests, such as class variables or mutable
constants, must be protected with a Mutex (or held in a database or Redis).
Processes instead of threads
The template also includes a commented-out server.cluster(thread_count, "-w", "--workers"),
which starts separate processes sharing the port instead. Forking is deprecated in
Crystal, so prefer threads. With processes nothing is shared, so caches and MCP
sessions exist once per process.
threads resizes Crystal's default
execution context.
Check the Crystal documentation for the compiler flags your Crystal version needs
for multi-threading.
Signals#
SIGTERM,SIGINT(Ctrl+C) andSIGHUPclose the server gracefully.SIGUSR1togglestracelogging for your app's logs, see Logging.
Server options#
If you don't use the template, create and run the server yourself:
require "action-controller"
# ... require your controllers ...
require "action-controller/server"
server = ActionController::Server.new(port: 3000, host: "0.0.0.0")
server.run { puts "Listening on #{server.print_addresses}" }
To serve HTTPS directly, pass an OpenSSL::SSL::Context::Server as the first
argument: ActionController::Server.new(ssl_context, 3443, "0.0.0.0"). Most
deployments terminate TLS at a load balancer or reverse proxy instead.