Skip to content

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.

main.go
// 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:

  1. Start with context.Background(): builds and initializes every service, runs pending migrations, runs ready hooks, and binds the server configuration. If any step fails, Run returns the error before listening. See App lifecycle.
  2. Logs the route map (see Routing).
  3. Starts the HTTP server in a goroutine and logs server started with the environment and addr fields.
  4. Waits for SIGINT or SIGTERM, 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.

Shutdown is App.Stop(ctx), which Run calls with context.Background():

  1. Emits gas.SystemServerShuttingDown and waits for its subscribers to return. See Events.
  2. Calls http.Server.Shutdown, which closes the listeners and waits for in-flight requests to finish, for at most Server.ShutdownTimeout (default 30s) or until ctx ends, whichever is first. A shutdown error, including the timeout, is logged as server shutdown error and does not stop the sequence; Stop returns it at the end, joined with any errors from step 3.
  3. Calls Shutdown(ctx): emits gas.SystemShuttingDown, waits for its subscribers, then calls Close(ctx) on every active service in reverse initialization order. A failing Close does not stop the others; the errors are joined and returned. It logs shutdown complete at 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:

main.go
// 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.

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.

main.go
// 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).

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.

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, whose CheckHealth(ctx) calls CheckHealth on every active service that implements gas.HealthReporter: is the process alive, or would a restart fix it? This maps to a liveness probe.
  • gas.ReadyProvider, whose CheckReady(ctx) calls CheckReady on every active service that implements gas.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/service.go
// 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 ready

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).

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:

example/lambda-worker/cmd/main.go
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.