# 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: 2026-06-28
- Author: 友田 陽大
- Tags: Go, Echo, アーキテクチャ設計, 可観測性, セキュリティ, コスト最適化
- URL: https://tomodahinata.com/en/blog/go-echo-deployment-docker-distroless-ecs-cloud-run-graceful-shutdown-guide
- Category: Go & Echo in production
- Pillar guide: https://tomodahinata.com/en/blog/go-echo-framework-production-guide

## Key points

- Go is a single binary. With a CGO_ENABLED=0 multi-stage build → distroless/static:nonroot, make a minimal, least-privilege image with no shell or packages.
- v5 removed the Timeout middleware. Set server timeouts (ReadHeaderTimeout/Read/Write/Idle) on http.Server via StartConfig.BeforeServeFunc. An essential item for slowloris defense.
- The crux of zero-downtime deployment is SIGTERM. Pass signal.NotifyContext to StartConfig{GracefulTimeout}, and treat ErrServerClosed as a normal exit.
- Make GracefulTimeout shorter than the orchestrator's termination grace. The reverse gets it SIGKILL'd before cleanup. Drop readiness first to stop new inflow.
- Config is environment variables per 12-factor; secrets are in Secrets Manager, etc. Don't bake them into the image. Build for arm64 (Graviton) to cut cost too.

---

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](/blog/go-echo-framework-production-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](#3-server-timeouts-v5-sets-them-with-startconfig-beforeservefunc)). **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.**

```go
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`.

```dockerfile
# --- 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"]
```

| Setting | Effect |
| --- | --- |
| `CGO_ENABLED=0` | Static linking. Runs on distroless/static, cuts the libc dependency |
| `-trimpath` | Removes local paths from the binary (prevents info leakage, reproducibility) |
| `-ldflags="-s -w"` | Reduces size by removing debug info |
| `distroless/static` | No shell or packages = **minimal attack surface** |
| `nonroot` | Limits the damage on compromise (the principle of least privilege) |
| `COPY` dependencies first | Speeds 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.

```go
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
	},
}
```

| Timeout | What it protects |
| --- | --- |
| `ReadHeaderTimeout` | **slowloris** (an attack that sends headers slowly to occupy the connection) |
| `ReadTimeout` | Occupation by a huge/slow request body |
| `WriteTimeout` | Occupation by a client that doesn't receive the response |
| `IdleTimeout` | Stagnation of idle keep-alive connections |

A **per-handler** timeout (cutting off a heavy query) is expressed with `context.WithTimeout` ([the DB-layer article](/blog/go-echo-database-postgresql-pgx-sqlc-gorm-transaction-guide#1-最重要contextをハンドラからdbまで貫通させる)). The server level (connection) and the handler level (processing) are different; set both. Also cap the request body size with [`BodyLimit`](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide).

---

## 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.**

```go
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.

```go
// 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)](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide) and a correlation ID raises production observability a level ([OpenTelemetry integration](/blog/opentelemetry-observability-production-tracing-metrics-logs)).

---

## 6. ECS Fargate / Cloud Run practices

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

| Item | AWS ECS / Fargate | Google Cloud Run |
| --- | --- | --- |
| Port | Port mapping in the task definition | **Always read the `PORT` environment variable** |
| Termination signal | `SIGTERM` → `stopTimeout` (default 30 seconds) | `SIGTERM` → grace (default 10 seconds, extendable) |
| Consistency with grace | `GracefulTimeout` < `stopTimeout` | `GracefulTimeout` < termination grace |
| Client IP | Via ALB → set `Echo.IPExtractor` | Via proxy → same as left |
| Secrets | Secrets Manager / SSM → env injection | Secret Manager → env/volume |
| Autoscale | Target Tracking, etc. | Request-concurrency-based (scale-to-zero possible) |

If you use `c.RealIP()` in [rate limiting](/blog/go-echo-middleware-cors-csrf-jwt-rate-limit-security-guide#9-ratelimiter重い処理の前で過剰アクセスを遮断) 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](/blog/aws-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](/blog/azure-container-apps-production-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](/blog/ai-driven-development-quality-gates-ci-type-safety-test-security).

---

## 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](/blog/go-echo-framework-production-guide).
