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"]
| 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=arm64and 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
},
}
| 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). 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
GracefulTimeoutalways shorter than the orchestrator's termination grace (ECS'sstopTimeout, Kubernetes'sterminationGracePeriodSeconds, etc.). The reverse gets itSIGKILL'd mid-cleanup, and requests end up cut after all. E.g., with a 30-second grace, makeGracefulTimeout10–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 readiness503to 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.
| 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 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.envis 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-lintandgovulncheck(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
- Config is 12-factor environment variables. Missing required ones, fail-fast at startup.
- A CGO_ENABLED=0 multi-stage + distroless/nonroot for a minimal, least-privilege image.
- v5 sets server timeouts with
BeforeServeFunc(ReadHeaderTimeoutfor slowloris defense). StartConfig{GracefulTimeout}+signal.NotifyContext,ErrServerClosedis a normal exit.GracefulTimeoutshorter than the orchestrator grace. Drop readiness first to stop inflow.- Separate liveness and readiness. Look at dependencies only in readiness.
- 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.