Deployment
A Gas app is one Go binary that serves HTTP. This page covers what that binary does at startup and shutdown, the settings it reads, and the hooks it gives a platform for health checks. How to build and host the binary is up to the platform.
Running
Section titled “Running”// Run starts everything, serves, and blocks until SIGINT or SIGTERM, then// shuts down gracefully.func runApp() { app := gas.NewApp(options()...)
if err := app.Run(); err != nil { log.Fatal(err) }}App.Run does, in order:
Startwithcontext.Background(): builds and initializes every service, runs pending migrations, runs ready hooks, and binds the server configuration. If any step fails,Runreturns the error before listening. See App lifecycle.- Logs the route map (see Routing).
- Starts the HTTP server in a goroutine and logs
server startedwith theenvironmentandaddrfields. - Waits for
SIGINTorSIGTERM, or for the server to fail.
On a signal it logs shutdown signal received with the signal name, then
shuts down as described below and returns the result. If the server fails
instead, for example because the port is taken, Run still shuts down, so the
services Start initialized are closed, and returns the server error joined
with any shutdown error.
Graceful shutdown
Section titled “Graceful shutdown”Shutdown is App.Stop(ctx), which Run calls with context.Background():
- Emits
gas.SystemServerShuttingDownand waits for its subscribers to return. See Events. - Calls
http.Server.Shutdown, which closes the listeners and waits for in-flight requests to finish, for at mostServer.ShutdownTimeout(default30s) or untilctxends, whichever is first. A shutdown error, including the timeout, is logged asserver shutdown errorand does not stop the sequence;Stopreturns it at the end, joined with any errors from step 3. - Calls
Shutdown(ctx): emitsgas.SystemShuttingDown, waits for its subscribers, then callsClose(ctx)on every active service in reverse initialization order. A failingClosedoes not stop the others; the errors are joined and returned. It logsshutdown completeat the end.
Server.ShutdownTimeout bounds only the HTTP drain in step 2. In Run, the
services’ Close calls get context.Background(), so nothing bounds them. Once
Run has received a signal it keeps handling SIGINT and SIGTERM until it
returns, so a second signal does not interrupt a slow shutdown; the platform’s
own kill deadline (such as a container runtime’s grace period) is what ends it.
To bound the whole shutdown, or use your own signal handling, drive the lifecycle yourself:
// The same lifecycle with your own contexts: Start, Serve in a goroutine, and// Stop with a deadline that also bounds every service's Close.func runManually() error { app := gas.NewApp(options()...)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop()
if err := app.Start(ctx); err != nil { return err }
srvErr := make(chan error, 1) go func() { srvErr <- app.Serve() }()
var err error select { case <-ctx.Done(): case err = <-srvErr: }
stopCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() return errors.Join(err, app.Stop(stopCtx))}Serve returns nil after a clean shutdown. Started this way, the app logs no
route map.
Server settings
Section titled “Server settings”When Start runs, the app binds its gas.Config from the registered
gas.ConfigProvider. The server settings live under the server key:
| Key | Type | Default | Description |
|---|---|---|---|
server.host |
string | 0.0.0.0 |
Listen address: an IP address or an RFC 1123 hostname |
server.port |
int | 8080 |
Listen port |
server.readtimeout |
duration | 5s |
http.Server.ReadTimeout: reading the whole request |
server.readheadertimeout |
duration | 5s |
http.Server.ReadHeaderTimeout: reading the request headers; 0 falls back to ReadTimeout |
server.writetimeout |
duration | 10s |
http.Server.WriteTimeout: writing the response |
server.idletimeout |
duration | 2m |
http.Server.IdleTimeout: keep-alive idle time |
server.shutdowntimeout |
duration | 30s |
Longest wait for in-flight requests at shutdown |
server.maxjsonbodybytes |
int | 1048576 (1 MiB) |
Most bytes BindJSON reads; 0 or negative means no limit |
Durations accept Go duration strings such as 45s or 2m. With the
environment provider, SERVER__PORT=9090 sets server.port; the naming rule is
on Config.
// The App binds its server settings from the registered ConfigProvider when it// starts. Environment variables like SERVER__PORT=9090 or// SERVER__SHUTDOWNTIMEOUT=60s reach them through the env provider, and GAS_ENV// selects the environment through the gasenv extension.func options() []gas.Option { cfg := config.New( config.WithProvider(providers.NewEnvProvider()), config.WithExtension(gasenv.NewExtension()), ) if err := cfg.Load(); err != nil { log.Fatal(err) }
return []gas.Option{ gas.WithServiceInstance[gas.ConfigProvider](cfg), gas.WithSingletonService[*Health](NewHealth), }}Binding validates server.host by shape only, with no DNS lookup. A host that
is neither an IP address nor an RFC 1123 hostname, or longer than 253
characters, fails Start with a gas: failed to bind config error. With no
ConfigProvider registered, nothing is bound and the defaults apply.
server.maxjsonbodybytes applies only to BindJSON; a handler that reads the
body itself is not limited by it (see
Handlers and Context).
Changing the http.Server
Section titled “Changing the http.Server”The address and timeouts come from the server.* settings above. App.Server()
is the escape hatch for the http.Server fields that have no config key, such
as TLSConfig, ErrorLog, ConnState, BaseContext and MaxHeaderBytes.
Do not use it for a setting the config already covers. It returns the
*http.Server the app serves, built on first call:
// capHeaderBytes caps the request line and headers at 64 KiB, below net/http's// 1 MiB default. MaxHeaderBytes has no server.* key, so Start leaves it as set.func capHeaderBytes(app *gas.App) { app.Server().MaxHeaderBytes = 64 << 10}A single route that needs longer to read a large upload sets its own deadline
in the handler with http.NewResponseController(ctx.ResponseWriter()).SetReadDeadline(...),
instead of turning off server.readtimeout for every route.
A server built before Start starts from the configuration as it stands at that point, the defaults unless you changed app.Config(). When Start binds the
configuration, it writes the bound address and timeouts to every one of those
fields still holding the value the server was built with, and keeps any field
changed since. A subscriber to gas.AppConfigUpdated, which Start emits
after this, can also set fields, and what it sets stays. Once Serve has begun,
Start no longer writes to the server.
Health and readiness
Section titled “Health and readiness”Gas does not mount health endpoints. It gives you the checks, and you choose the paths. Every app’s container holds two aggregates:
gas.HealthProvider, whoseCheckHealth(ctx)callsCheckHealthon every active service that implementsgas.HealthReporter: is the process alive, or would a restart fix it? This maps to a liveness probe.gas.ReadyProvider, whoseCheckReady(ctx)callsCheckReadyon every active service that implementsgas.ReadyReporter: should traffic be routed here right now? This maps to a readiness probe.
Both run the checks concurrently and return a map from service name to result,
nil meaning healthy or ready. Services that do not implement the reporter
interface are left out of the map. A check that panics is reported as an error
starting with panic: instead of crashing the process. Only active services
are checked: a service closed with CloseService, or by shutdown, is no longer
in the map.
// Health serves liveness and readiness endpoints from the aggregates the// container always holds.type Health struct { router *gas.Router}
func NewHealth(router *gas.Router) *Health { return &Health{router: router} }
func (h *Health) Name() string { return "health" }func (h *Health) Close(context.Context) error { return nil }
func (h *Health) Init(context.Context) error { h.router.Get(h, "/healthz", func(ctx gas.Context, p gas.HealthProvider) error { return report(ctx, p.CheckHealth(ctx)) }) h.router.Get(h, "/readyz", func(ctx gas.Context, p gas.ReadyProvider) error { return report(ctx, p.CheckReady(ctx)) }) return nil}
// report answers 200 when every reporting service returned nil, and 503 with// the failures otherwise.func report(ctx gas.Context, results map[string]error) error { failed := make(map[string]string) for name, err := range results { if err != nil { failed[name] = err.Error() } } if len(failed) > 0 { return ctx.JSON(http.StatusServiceUnavailable, map[string]any{"status": "fail", "failed": failed}) } return ctx.JSON(http.StatusOK, map[string]string{"status": "ok"})}The GET endpoints pass cross-origin protection, so probes need no
configuration.
Among the modules, gas/database and the Valkey backend of gas/cache implement both reporters; gas/migrate, gas/ui, the SQS backend of gas/queue, and the SES backend of gas/email implement readiness. Each module page describes what its checks test. A service of your own reports by implementing either interface:
func (s *Service) CheckReady(ctx context.Context) error // nil means readyEnvironments
Section titled “Environments”The gasenv extension of gas/config sets the
environment from GAS_ENV or a config provider: development, testing,
staging, or production, falling back to development for a missing or
unknown value. The app binds it into Config().GasEnv at Start. Without the
extension, nothing sets it unless a provider sets the GasEnv key, and it stays
empty.
Core uses it in two places: Run logs the route map as one table in
development and as one line per route otherwise, and server started carries
it in the environment field. Modules that change behavior by environment say
so on their pages. Your own configuration struct gets it by embedding
gas.WithGasEnv, with helpers such as IsProduction() and
IsProductionLike() (staging or production).
AWS Lambda and other non-HTTP processes
Section titled “AWS Lambda and other non-HTTP processes”gas.Worker is an app without the router and HTTP server: the same container,
services, migrations, ready hooks, and events. Lambda calls for Start and
Shutdown rather than Run, because the Lambda runtime owns the process loop.
This is the main.go of the lambda-worker example:
package main
import ( "context" "log"
"github.com/aws/aws-lambda-go/lambda"
"github.com/gasmod/gas" "github.com/gasmod/gas/example/lambda-worker/app")
// worker holds the Gas worker at package level so it survives across// Lambda invocations (Lambda reuses the execution environment).var worker *gas.Worker
func init() { worker = app.NewWorker()
// Start runs: InitServices (BuildAll + Init) → migrations → ready // hooks. Non-blocking — does not start an HTTP server or wait for // signals. If the database is unreachable, config is invalid, or // NotificationQueueURL is missing, this fails fast. if err := worker.Start(context.Background()); err != nil { log.Fatalf("failed to start worker: %s\n", err) }}
func main() { // Resolve the handler once — all deps were injected by the container // during Start. This is the only manual resolve needed. h := worker.ServiceContainer().MustResolve[*app.Handler]()
// WithEnableSIGTERM registers a callback that Lambda invokes before // freezing the execution environment. Worker.Shutdown emits // SystemShuttingDown and closes services in reverse init order. lambda.StartWithOptions(h.Handle, lambda.WithEnableSIGTERM(func() { if err := worker.Shutdown(context.Background()); err != nil { log.Printf("shutdown error: %s\n", err) } }))}Worker.Run exists for long-running non-HTTP processes such as queue
consumers: it starts, logs worker started, waits for SIGINT or SIGTERM,
and shuts down the same way an app does, without the HTTP steps. See
Workers.