Skip to main content
友田 陽大
Go & Echo in production
Go
Echo
アーキテクチャ設計
可観測性
セキュリティ
コスト最適化

The complete Echo production-deployment guide: zero-downtime operation with multi-stage Docker, distroless, server timeouts, and graceful shutdown

An implementation guide to deploying Go Echo (v5) to production. With real code: 12-factor environment-variable configuration, a CGO_ENABLED=0 multi-stage Docker with distroless/nonroot, http.Server timeouts via StartConfig.BeforeServeFunc (the Timeout middleware is removed in v5), graceful shutdown that doesn't miss SIGTERM, liveness/readiness health checks, ECS Fargate / Cloud Run practices, secret management, and cost optimization.

Published
Reading time
8 min read
Author
友田 陽大
Share

Between "it works locally" and "it can be operated with zero downtime in production" lies a deep valley. A 502 appears on every deployment, memory overflows on a huge request, a slow client keeps holding a connection (slowloris), credentials are baked into the container image — these are deficiencies in deployment design, not app bugs.

This article is the deployment chapter of the Go Echo production-operations guide. Maximally leveraging Go's strength of being a "single binary," it implements a minimal, secure, zero-downtime deployment with Echo v5's accurate API.

Rules for this article: Echo's API is based on the official documentation / source (v5, as of June 2026). In particular, v5 removed middleware.Timeout, and how to set server timeouts has changed (Chapter 3). Secrets (DB URL, signing key) are premised on environment variables / a secrets manager, never baked into the code or the image.


1. Config is 12-factor: read from environment variables

Read configuration values (port, DB URL, log level, keys) from environment variables. This lets you reuse the same image across dev/staging/prod without rebuilding.

type Config struct {
	Port        string
	DatabaseURL string
	JWTKey      []byte
	LogLevel    string
}

func LoadConfig() (*Config, error) {
	port := os.Getenv("PORT")
	if port == "" {
		port = "8080" // 既定値(Cloud Run は PORT を注入してくる)
	}
	dsn := os.Getenv("DATABASE_URL")
	if dsn == "" {
		return nil, errors.New("DATABASE_URL is required") // 起動時に fail-fast
	}
	return &Config{
		Port:        port,
		DatabaseURL: dsn,
		JWTKey:      []byte(os.Getenv("JWT_SIGNING_KEY")),
		LogLevel:    os.Getenv("LOG_LEVEL"),
	}, nil
}

If a required setting is missing, error immediately at startup (fail-fast). "Refusing to start" is far safer than "starting and then crashing on a missing setting."


2. Multi-stage Docker: a minimal, least-privilege image

Leveraging Go's single binary, separate the build environment and the runtime environment. The runtime image is distroless (no shell, no package manager) + nonroot.

# --- build stage ---
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download                       # 依存だけ先に取りレイヤキャッシュを効かせる
COPY . .
# CGO 無効で静的リンク、-trimpath とシンボル除去で軽量化・再現性向上
RUN CGO_ENABLED=0 GOOS=linux go build \
      -trimpath -ldflags="-s -w" \
      -o /app ./cmd/server

# --- runtime stage ---
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
USER nonroot:nonroot                       # 非 root 実行(最小権限)
EXPOSE 8080
ENTRYPOINT ["/app"]
SettingEffect
CGO_ENABLED=0Static linking. Runs on distroless/static, cuts the libc dependency
-trimpathRemoves local paths from the binary (prevents info leakage, reproducibility)
-ldflags="-s -w"Reduces size by removing debug info
distroless/staticNo shell or packages = minimal attack surface
nonrootLimits the damage on compromise (the principle of least privilege)
COPY dependencies firstSpeeds up the build with layer caching (cost efficiency)

Cost optimization: if the production target is arm64 (AWS Graviton / Cloud Run), building with GOARCH=arm64 and running on arm-family instances gives a cheaper unit price at equivalent performance. Go's cross-compilation is easy, so it's a language that easily benefits from multi-arch builds.


3. Server timeouts: v5 sets them with StartConfig.BeforeServeFunc

This is an important v5 pitfall. middleware.Timeout, which existed in v4, was removed in v5. Instead, set connection-level timeouts on http.Server. In v5, StartConfig.BeforeServeFunc (a callback that can touch *http.Server just before server startup) is its proper place.

sc := echo.StartConfig{
	Address:         ":" + cfg.Port,
	GracefulTimeout: 10 * time.Second,
	BeforeServeFunc: func(s *http.Server) error {
		// slowloris 等の遅延攻撃・リソース占有を防ぐ
		s.ReadHeaderTimeout = 5 * time.Second   // ヘッダ読み取りの上限(最重要)
		s.ReadTimeout = 15 * time.Second        // ボディ含むリクエスト全体
		s.WriteTimeout = 30 * time.Second       // レスポンス書き込み
		s.IdleTimeout = 120 * time.Second       // keep-alive 接続のアイドル上限
		return nil
	},
}
TimeoutWhat it protects
ReadHeaderTimeoutslowloris (an attack that sends headers slowly to occupy the connection)
ReadTimeoutOccupation by a huge/slow request body
WriteTimeoutOccupation by a client that doesn't receive the response
IdleTimeoutStagnation of idle keep-alive connections

A per-handler timeout (cutting off a heavy query) is expressed with context.WithTimeout (the DB-layer article). The server level (connection) and the handler level (processing) are different; set both. Also cap the request body size with BodyLimit.


4. Graceful shutdown: don't miss SIGTERM

On every deployment and scale-in, the orchestrator sends SIGTERM first. Ignore it and in-flight requests are cut, becoming 502s. v5 is unified to passing signal.NotifyContext to StartConfig{GracefulTimeout}. The correct practice is to treat http.ErrServerClosed as a normal exit.

func main() {
	cfg, err := LoadConfig()
	if err != nil {
		slog.Error("config error", "error", err)
		os.Exit(1)
	}

	e := buildEcho(cfg) // ルート・ミドルウェア・DI 済みの Echo

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	sc := echo.StartConfig{
		Address:         ":" + cfg.Port,
		GracefulTimeout: 10 * time.Second,
		BeforeServeFunc: applyServerTimeouts, // 第3章
		OnShutdownError: func(err error) {
			e.Logger.Error("graceful shutdown error", "error", err)
		},
	}

	// ErrServerClosed は SIGTERM での正常停止。エラー扱いしない
	if err := sc.Start(ctx, e); err != nil && !errors.Is(err, http.ErrServerClosed) {
		e.Logger.Error("server failed", "error", err)
		os.Exit(1)
	}
	slog.Info("server stopped gracefully")
}

The flow after receiving SIGTERM: ① stop accepting new connections → ② wait for in-flight requests to complete within GracefulTimeout (default 10 seconds) → ③ clean up the DB pool, etc. (defer pool.Close()) → ④ exit.

A fatal config consistency: make GracefulTimeout always shorter than the orchestrator's termination grace (ECS's stopTimeout, Kubernetes's terminationGracePeriodSeconds, etc.). The reverse gets it SIGKILL'd mid-cleanup, and requests end up cut after all. E.g., with a 30-second grace, make GracefulTimeout 10–20 seconds.


5. Health checks: separate liveness and readiness

The orchestrator judges "is it alive" and "is it OK to send traffic" with health checks. Not confusing these two is the key to zero-downtime operation.

// liveness: プロセスが生きているか(依存はチェックしない。落ちていれば再起動される)
e.GET("/healthz", func(c *echo.Context) error {
	return c.NoContent(http.StatusOK)
})

// readiness: トラフィックを受けられるか(DB 等の依存も確認)
e.GET("/readyz", func(c *echo.Context) error {
	ctx, cancel := context.WithTimeout(c.Request().Context(), 2*time.Second)
	defer cancel()
	if err := pool.Ping(ctx); err != nil {
		return c.JSON(http.StatusServiceUnavailable, map[string]string{"db": "down"})
	}
	return c.NoContent(http.StatusOK)
})
  • liveness (/healthz): doesn't look at dependencies. Even if the DB temporarily goes down, the process shouldn't be restarted (restarting won't fix it).
  • readiness (/readyz): looks at dependencies. While the DB is down, don't send traffic (but don't kill the process).
  • Coordination with graceful stop: ideally, on receiving SIGTERM, first make readiness 503 to stop new inflow from the load balancer, then handle existing requests to completion.

Combining these health checks with RequestLogger (slog) and a correlation ID raises production observability a level (OpenTelemetry integration).


6. ECS Fargate / Cloud Run practices

Echo, as a resident container, loads straightforwardly onto any container platform. Just grasp the per-platform key points.

ItemAWS ECS / FargateGoogle Cloud Run
PortPort mapping in the task definitionAlways read the PORT environment variable
Termination signalSIGTERMstopTimeout (default 30 seconds)SIGTERM → grace (default 10 seconds, extendable)
Consistency with graceGracefulTimeout < stopTimeoutGracefulTimeout < termination grace
Client IPVia ALB → set Echo.IPExtractorVia proxy → same as left
SecretsSecrets Manager / SSM → env injectionSecret Manager → env/volume
AutoscaleTarget Tracking, etc.Request-concurrency-based (scale-to-zero possible)

If you use c.RealIP() in rate limiting or audit logs, behind a load balancer, unless you set a trusted upstream with Echo.IPExtractor, you'll grab the wrong IP via header spoofing.

ECS/Fargate autoscaling, Blue/Green, and cost optimization are in the ECS Fargate production guide, and Cloud Run's scale-to-zero, concurrency, and billing are contiguous with the serverless-container thinking in the Azure Container Apps guide.


7. Secret management and image hygiene

  • Don't bake into the image: committing ENV JWT_KEY=... or .env is strictly forbidden. Inject keys at runtime via a secrets manager → environment variables.
  • .dockerignore: exclude .git, .env, tests, and local config from the image (prevents leakage and bloat).
  • Image scanning: gate on container vulnerability scanning (Trivy, etc.) in CI. Combine golangci-lint and govulncheck (Go's known-vulnerability check) too.
  • Keyless CI/CD: deployment authentication should be OIDC (GitHub Actions → AWS/GCP temporary credentials), not long-lived keys. Don't place long-lived access keys in repository Secrets.

This philosophy of image hygiene and keyless CI/CD is the same as the "mechanically enforce secret/vulnerability scanning in CI" design covered in AI-driven development quality gates.


Conclusion: the 7 principles of zero-downtime deployment

  1. Config is 12-factor environment variables. Missing required ones, fail-fast at startup.
  2. A CGO_ENABLED=0 multi-stage + distroless/nonroot for a minimal, least-privilege image.
  3. v5 sets server timeouts with BeforeServeFunc (ReadHeaderTimeout for slowloris defense).
  4. StartConfig{GracefulTimeout} + signal.NotifyContext, ErrServerClosed is a normal exit.
  5. GracefulTimeout shorter than the orchestrator grace. Drop readiness first to stop inflow.
  6. Separate liveness and readiness. Look at dependencies only in readiness.
  7. Don't bake secrets into the image, and gate on OIDC keyless CI/CD and image scanning.

Deployment design is the final process that makes the correctness of your code withstand the realities of production (restart, scale, attack, failure). Echo's single binary + v5's StartConfig are designed so you can assemble this process straightforwardly and accurately. The whole cluster can be followed from the Go Echo production-operations guide.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

I can take on the implementation from this article as an engagement

I build Go / Echo backends, from design to production

API design and migration to Echo v5, clean architecture (Controller/UseCase/Repository + DI), middleware and security, centralized error handling, graceful shutdown, and testing/CI. With experience building a clean-architecture backend in Go/Echo + google/wire, I implement APIs that don't fall over, are traceable, and are easy to change.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading