Skip to main content
友田 陽大
Go & Echo in production
Go
Echo
アーキテクチャ設計
型安全
可観測性
セキュリティ

Go Echo Framework Production-Operations Guide: Building APIs That Don't Fall Over with v5's New API, Routing, Context, and Graceful Shutdown

An implementation guide to operating Go's Echo framework at production quality. Faithful to the official documentation (v5), it explains the v4→v5 breaking changes (*echo.Context, slog, StartConfig), routing, Context, binding and validation, centralized error handling, graceful shutdown, testing, Docker deployment, and the Echo adoption decision—all in real code.

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

"I want to stand up an API in Go. net/http is too thin, and a full stack is heavy"—what's in between is Echo. It has the lightness of running with one line of @app.get, plus the parts production needs (router, middleware, binding, centralized error handling) all from the start. But the moment you put it in production, the things to decide multiply at once. Which version do you use? How do you validate requests? Won't a panic bring down the whole thing? Won't in-flight requests be dropped at deploy time? When it falls over, can you trace it from the logs?

This article is an implementation guide to operating Echo at production quality. Whereas the official tutorial teaches you up to "making it work," here I focus on the decision criteria and code for building "doesn't fall over, can be traced, easy to change." As source material, I'll weave in design decisions from a restaurant-matching site for foreign tourists whose backend I actually built in Go/Echo (clean architecture with Echo + google/wire, Controller/UseCase/Repository separation, go test / golangci-lint enforced in CI).

The rules of this article: API specs and recommended patterns are based on the Echo official documentation (v5, as of June 2026). Echo v5 is the current stable line, and v4 gets security fixes and bug fixes until 2026-12-31. Specs are revised, so always confirm the latest behavior in the official documentation before going to production. The code is arranged in a form usable in real operation, but secrets (signing keys, DB URLs, API keys) are assumed to be in environment variables (never hardcode).


0. Why Echo—grasp its position in 3 lines

Before design decisions, grasp what Echo stands on. The official docs cite these three as its foundation.

  • A thin layer over net/http: Echo is a "high-performance, minimalist" framework built on the standard library's net/http. It doesn't deviate greatly from Go's standard practice.
  • An optimized HTTP router: a radix-tree-based router that cleverly prioritizes static, parameter, and wildcard. Near-zero-allocation route lookup is its selling point.
  • Batteries-included: middleware, JSON/XML/form data binding, template rendering, centralized error handling, automatic TLS via Let's Encrypt, and HTTP/2 from the start.

In other words, Echo's value is exactly in between "net/http's plainness" and "the parts production needs." Compared to Gin, its middleware design is more consistent, and unlike Fiber (fasthttp-based), it's net/http-compatible, so you can use the standard ecosystem (context, http.Handler, various middleware) as-is. The details of the adoption decision are covered in the final chapter.


1. Most important: grasp "what changed" in v5 first

Skip this and you fall into a state where you copy-paste v4 samples from the web and they don't compile, or behave subtly differently. Echo v5 is a milestone release with breaking changes from v4. Get the diff into your head before you start writing.

1.1 The minimal server (v5's official Hello World)

package main

import (
	"log/slog"
	"net/http"

	"github.com/labstack/echo/v5"
	"github.com/labstack/echo/v5/middleware"
)

// ハンドラ:v5 では *echo.Context(構造体ポインタ)を受け取る
func hello(c *echo.Context) error {
	return c.String(http.StatusOK, "Hello, World!")
}

func main() {
	e := echo.New()

	// ミドルウェア
	e.Use(middleware.RequestLogger()) // v4 の middleware.Logger() は廃止
	e.Use(middleware.Recover())       // panic を回復し集中エラーハンドラへ

	// ルート
	e.GET("/", hello)

	// 起動
	if err := e.Start(":8080"); err != nil {
		slog.Error("failed to start server", "error", err)
	}
}

Installation is as follows. Echo v5 requires Go 1.25 or later (the most recent four Go major releases).

go get github.com/labstack/echo/v5

1.2 The decisive diff of v4 → v5

Itemv4v5Impact
Handler signaturefunc(c echo.Context) error (interface)func(c *echo.Context) error (struct pointer)All handlers and all middleware need fixing
LoggingA custom Logger interfaceThe standard log/slog (e.Logger is *slog.Logger)Structured logging becomes standard
Request logmiddleware.Logger()middleware.RequestLogger()The old Logger is removed from the core
Timeoutmiddleware.Timeout()Removed from the coreSubstitute with http.Server's timeout / context
Graceful stope.Shutdown(ctx) / e.Close()Consolidated into StartConfig{GracefulTimeout}Startup code needs rewriting
Route return*echo.RouteRouteInfo (filter with the Routes collection)The way to write reverse routing changes
HTTPError.Messageinterface{}string (NewHTTPError(code, msg) non-variadic)The message-formatting policy becomes clear
Error-handler signaturefunc(err error, c echo.Context)func(c *echo.Context, err error)The argument order is reversed
c.Response()*echo.Responsehttp.ResponseWriter (get it with echo.UnwrapResponse)The way to write low-level operations changes
BinderFormParam*FormValue*, Bind(c, target) with reversed signatureForm-system API names change
Type-safe extractionValueBinderGenerics (PathParam[T]/QueryParam[T])Compile-time type safety advances
echo.MapPresentRemovedUse map[string]any directly
Constantsecho.CONNECT etc.Removed → http.MethodConnect etc.Lean on standard constants
JWTecho-jwt/v4echo-jwt/v5 (a separate module outside the core)import change
Testinge.NewContexte.NewContext + the new echotest packageReduced boilerplate

A realistic migration guideline: new projects choose v5. Existing v4 projects should make a migration plan with an eye on the end of support on 2026-12-31, but there's no need to break things in a hurry. API_CHANGES_V5.md (official) is the primary source for the diff. All code in this article is written in v5.


2. Routing: the priority of static → parameter → wildcard

The key to Echo's router is that the priority is decided by the kind of match. Understand this and you won't agonize over "why this route was picked."

e := echo.New()

e.GET("/users/:id", getUser)        // パラメータ
e.POST("/users", createUser)
e.PUT("/users/:id", updateUser)
e.DELETE("/users/:id", deleteUser)
e.GET("/users/profile", profile)    // 静的(:id より優先される)
e.GET("/files/*", serveFiles)       // ワイルドカード

The signature of each method (GET/POST/PUT/DELETE/PATCH…) is as follows, with the return value RouteInfo.

func (e *Echo) GET(path string, h echo.HandlerFunc, m ...echo.MiddlewareFunc) *echo.RouteInfo
PatternKindExamplePriority
/users/profileStatic/users/profile1 (highest)
/users/:idParameter/users/422
/files/*Wildcard/files/css/app.css3

Because it's evaluated in the order "static → parameter → wildcard," even if you register both /users/profile and /users/:id, /users/profile is absorbed into the static route as intended. It's deterministic behavior, independent of order.

2.1 Reading path parameters and wildcards

func getUser(c *echo.Context) error {
	id := c.Param("id") // "/users/42" → "42"
	return c.String(http.StatusOK, id)
}

// ワイルドカードは "*" で受ける
e.GET("/files/*", func(c *echo.Context) error {
	return c.String(http.StatusOK, c.Param("*")) // "/files/a/b.css" → "a/b.css"
})

Left as a string, it's not type-safe. A numeric ID like "42" should, per the production practice, be converted to int64 with the type-safe binder described later, dropping conversion failures to 400 (details in the binding & validation article).

2.2 Route groups: bundle cross-cutting concerns in one place

Auth, versioning, and tenant boundaries are bundled with groups in the Echo style. You can apply a prefix and middleware together.

// /api/v1 配下に共通ミドルウェアを適用
api := e.Group("/api/v1")
api.Use(middleware.CORS("https://app.example.com"))

// 認証が必要な管理 API は、さらにネストしたグループに切る
admin := api.Group("/admin")
admin.Use(requireAdmin) // 自作の認可ミドルウェア
admin.GET("/metrics", metrics) // GET /api/v1/admin/metrics
admin.GET("/users", listUsers) // GET /api/v1/admin/users

Groups can be nested, expressing permission boundaries like directories: "public API," "auth-required API," "admin-only API." The design of middleware application levels (whole route / group / individual route) and order is dug into in the complete middleware guide.


3. Context: read and write complete with one *echo.Context

An Echo handler receives one *echo.Context, and from it does both reading the request and writing the response. Becoming a struct pointer in v5 brought pointer handling closer to standard Go feel.

3.1 How to write responses

func handler(c *echo.Context) error {
	// JSON(API で最頻出。構造体をそのまま渡す)
	return c.JSON(http.StatusOK, map[string]any{"ok": true})
}

// 用途別のレスポンスヘルパー
c.String(http.StatusOK, "plain text")
c.HTML(http.StatusOK, "<b>hi</b>")
c.XML(http.StatusOK, payload)
c.Blob(http.StatusOK, "application/pdf", pdfBytes)
c.Stream(http.StatusOK, "application/octet-stream", reader) // 大きな本文をストリーム
c.NoContent(http.StatusNoContent)                            // 204
c.Redirect(http.StatusFound, "/elsewhere")                   // 302
c.File("public/index.html")                                  // ファイルを直接配信
c.Attachment("report.csv", "report.csv")                     // ダウンロードを促す

3.2 Reading the request and "default-equipped" helpers

id := c.Param("id")                            // パスパラメータ
q := c.QueryParam("q")                         // クエリ文字列
name := c.FormValue("name")                    // フォーム値
ua := c.Request().Header.Get(echo.HeaderUserAgent) // ヘッダ

// 値がないときの既定値を一行で(v5 の利便メソッド)
page := c.QueryParamOr("page", "1")
sort := c.FormValueOr("sort", "created_at")

3.3 Set/Get: pass values from middleware to the handler

Used when passing to the handler a user resolved in the auth middleware or a request ID. This is the key to keeping middleware and the handler loosely coupled.

// ミドルウェア側:認証済みユーザーを詰める
func authMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
	return func(c *echo.Context) error {
		user := resolveUser(c) // トークン検証など
		c.Set("user", user)
		return next(c)
	}
}

// ハンドラ側:型アサーションで取り出す(境界で必ず ok を確認)
func me(c *echo.Context) error {
	user, ok := c.Get("user").(*User)
	if !ok {
		return echo.ErrUnauthorized
	}
	return c.JSON(http.StatusOK, user)
}

A note on low-level operations: in v5, c.Response() returns an http.ResponseWriter. When you need Echo's own *Response (the Committed flag, etc.), extract it with echo.UnwrapResponse(c.Response()). It's used in the scene of judging "already sent?" in the centralized error handler (below).


4. Data binding and validation: kill invalid input at the boundary

Security principle: don't trust input coming from outside until it can be converted to a trustworthy type. Echo thinly supports this boundary processing with c.Bind and c.Validate, but the design responsibility is the implementer's.

4.1 Do not Bind clean input directly to business structs

c.Bind(&dto) flows path parameters, query, and the request body (JSON/XML/form) into a struct per struct tags.

// ❌ DB エンティティに直接 Bind すると、IsAdmin など意図しないフィールドまで上書きされ得る
// ✅ 受け口専用の DTO を定義し、検証してからドメインに移す
type CreateUserDTO struct {
	Name  string `json:"name"  validate:"required,min=2,max=50"`
	Email string `json:"email" validate:"required,email"`
}

func (h *UserHandler) Create(c *echo.Context) error {
	var dto CreateUserDTO
	if err := c.Bind(&dto); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
	}
	if err := c.Validate(&dto); err != nil {
		return err // 集中エラーハンドラが 400/422 に整形
	}

	user, err := h.users.Create(c.Request().Context(), dto.Name, dto.Email)
	if err != nil {
		return err
	}
	return c.JSON(http.StatusCreated, user)
}

Tags are used per source (json/xml=body, query=query, param=path, form=form, header=header). Headers are outside the target of c.Bind—call echo.BindHeaders(c, &dto) explicitly.

4.2 Registering the validator

Echo doesn't force a specific validation library. Wrap the de-facto github.com/go-playground/validator/v10 in the echo.Validator interface and register it.

import "github.com/go-playground/validator/v10"

type CustomValidator struct {
	validator *validator.Validate
}

func (cv *CustomValidator) Validate(i any) error {
	if err := cv.validator.Struct(i); err != nil {
		// 422 にして「どのフィールドがなぜ落ちたか」を返すのが本番の作法
		return echo.NewHTTPError(http.StatusUnprocessableEntity, err.Error())
	}
	return nil
}

func main() {
	e := echo.New()
	e.Validator = &CustomValidator{validator: validator.New()}
	// ...
}

Structuring validation errors (a per-field 422 response), the type-safe fluent binder (echo.QueryParamsBinder(c).Int64s(...)), and implementing a custom Binder are fully covered in the binding & validation & error-design article.


5. Error handling: return errors without swallowing them

Echo's error handling is centralized. Handlers and middleware just return an error. One HTTPErrorHandler converts it to an HTTP response. You don't need to write if err != nil { c.JSON(...) ; return } all over.

// ステータス付きエラーを返す
return echo.NewHTTPError(http.StatusUnauthorized, "Please provide valid credentials")

// メッセージ省略時はステータステキストが使われる
return echo.NewHTTPError(http.StatusNotFound)

// センチネルエラー(定義済み)も使える
return echo.ErrBadRequest // 400
return echo.ErrForbidden  // 403

Returning a plain error becomes 500 Internal Server Error, and an *echo.HTTPError becomes that status and message. The default handler returns {"message": "..."} JSON.

5.1 A production-oriented centralized error handler

In production, swap to a handler that doesn't leak internal error details to the client, leaves full information in the logs, and returns consistent JSON. The v5 signature is func(c *echo.Context, err error) (the argument order is reversed from v4).

func customHTTPErrorHandler(c *echo.Context, err error) {
	// すでにレスポンス送信済みなら二重送信しない
	if resp, uErr := echo.UnwrapResponse(c.Response()); uErr == nil && resp.Committed {
		return
	}

	// ステータスコードの解決(HTTPError 等は HTTPStatusCoder を実装している)
	code := http.StatusInternalServerError
	var sc echo.HTTPStatusCoder
	if errors.As(err, &sc) {
		if s := sc.StatusCode(); s != 0 {
			code = s
		}
	}

	// 5xx は詳細を隠し、相関 ID 付きでログへ。4xx はメッセージを返す
	if code >= 500 {
		c.Logger().Error("unhandled error",
			"error", err.Error(),
			"path", c.Request().URL.Path,
			"request_id", c.Response().Header().Get(echo.HeaderXRequestID),
		)
		_ = c.JSON(code, map[string]string{"error": "internal server error"})
		return
	}
	_ = c.JSON(code, map[string]string{"error": http.StatusText(code)})
}

func main() {
	e := echo.New()
	e.HTTPErrorHandler = customHTTPErrorHandler
	// ...
}

The official docs state "forwarding errors to Sentry / Elasticsearch / Splunk etc. is also good to do from this centralized handler." Being able to consolidate log emission, metrics counting, and notification in one place is the biggest advantage of centralized error handling. Returning in Problem Details (RFC 9457) format and formatting validation errors are covered in the error-design article.


6. Middleware: the application level and order are everything

Middleware is func(echo.HandlerFunc) echo.HandlerFunc, inserting processing before and after the request. There are three application levels.

e.Use(middleware.Recover())                 // ① 全ルートに適用(最外周)
api := e.Group("/api", middleware.CORS(...)) // ② グループ単位
e.GET("/admin", handler, requireAdmin)       // ③ 個別ルート

The standards to put in first in production are as follows (note that the order has meaning).

e := echo.New()
e.Use(middleware.Recover())        // panic を最外周で回収(最初に置く)
e.Use(middleware.RequestID())      // 相関 ID を採番
e.Use(middleware.RequestLogger())  // 構造化アクセスログ(slog)
e.Use(middleware.Secure())         // セキュリティヘッダ
e.Use(middleware.BodyLimit(2_097_152)) // 本文サイズ上限 2MB(DoS 緩和)
e.Use(middleware.Gzip())           // レスポンス圧縮

middleware.Recover() recovers from a panic wherever it occurs in the chain, outputs a stack trace, and passes control to the centralized error handler. So a panic doesn't kill the whole process. The settings of CORS, CSRF, Secure, RateLimiter, JWT/KeyAuth, and custom middleware, and "in what order to line them up," are covered in the standalone article the complete Echo middleware guide.

A v5 note: middleware.Logger() and middleware.Timeout() were removed from the core. Access logging is RequestLogger(), and request timeouts are achieved with http.Server's timeouts or context.WithTimeout (inside the handler).


7. Graceful shutdown: the move that makes the biggest difference in production

The process is swapped every deploy. If you cut off in-flight requests at this time, users get a 502, and for payments, inconsistencies arise. Kubernetes / ECS / Cloud Run all first send SIGTERM at stop time, telling you "clean up within the grace period." Receiving this correctly is a production must.

v5 unified this practice into StartConfig (v4's e.Shutdown() / e.Close() are abolished). Make a context that waits for SIGTERM/SIGINT with signal.NotifyContext, and just pass it to StartConfig.Start(ctx, e).

package main

import (
	"context"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/labstack/echo/v5"
	"github.com/labstack/echo/v5/middleware"
)

func main() {
	e := echo.New()
	e.Use(middleware.Recover())
	e.Use(middleware.RequestLogger())

	e.GET("/slow", func(c *echo.Context) error {
		time.Sleep(5 * time.Second) // 処理中のリクエストを想定
		return c.JSON(http.StatusOK, map[string]string{"status": "done"})
	})

	// SIGINT / SIGTERM を受けると ctx がキャンセルされる
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	sc := echo.StartConfig{
		Address:         ":8080",
		GracefulTimeout: 10 * time.Second, // 既存リクエストの完了を最大10秒待つ
	}
	if err := sc.Start(ctx, e); err != nil {
		e.Logger.Error("server stopped", "error", err)
	}
}

When SIGTERM comes, Echo stops accepting new connections, waits within GracefulTimeout for in-flight requests to complete, and then exits. StartConfig also has fields for TLS settings and lifecycle callbacks, consolidating here the v4 startup code that called Start/StartTLS/StartAutoTLS individually.

Operational alignment: set GracefulTimeout shorter than the orchestrator's termination grace (terminationGracePeriodSeconds etc.). The reverse means you're force-killed with SIGKILL before cleanup finishes. The design of SIGTERM, idempotency, and zero-downtime in container environments is contiguous with the ECS/Fargate production guide and the Azure Container Apps production guide.


8. Observability: slog structured logs + correlation ID

"Can you trace it when it falls over" is decided by whether you can structure the logs and run one request through with a correlation ID. v5 unified the logger to the standard log/slog, so you get JSON logs as standard.

import (
	"context"
	"log/slog"
	"os"
)

logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))

e.Use(middleware.RequestLoggerWithConfig(middleware.RequestLoggerConfig{
	LogStatus:   true,
	LogURI:      true,
	LogError:    true,
	HandleError: true, // エラーを集中ハンドラへ正しく伝播させる
	LogValuesFunc: func(c *echo.Context, v middleware.RequestLoggerValues) error {
		if v.Error == nil {
			logger.LogAttrs(context.Background(), slog.LevelInfo, "REQUEST",
				slog.String("uri", v.URI),
				slog.Int("status", v.Status),
			)
		} else {
			logger.LogAttrs(context.Background(), slog.LevelError, "REQUEST_ERROR",
				slog.String("uri", v.URI),
				slog.Int("status", v.Status),
				slog.String("err", v.Error.Error()),
			)
		}
		return nil
	},
}))

RequestLoggerValues contains Status/URI/Error/Latency/RemoteIP and more. Combined with middleware.RequestID(), putting request_id on each log line lets you trace from one frontend error to the backend logs and traces with a single thread. Extension to traces and metrics via OpenTelemetry is covered Echo-specifically in the Echo observability (OpenTelemetry) article, and cross-platform design in the observability practical guide. File uploads (S3, presigned URLs), realtime (WebSocket/SSE), and API docs (OpenAPI/Swagger) are each dug into separately.


9. Architecture: keep handlers thin and testable with DI

Echo is, after all, the HTTP input/output layer. Writing business logic into the handler makes tests un-writable and changes scary. The structure I adopted for the restaurant-matching site's backend is Go's standard clean architecture + DI.

  • Controller (Echo handler): only HTTP input/output. Bind/Validate, call the UseCase, and turn the result into JSON.
  • UseCase: the application's use case. Pure logic that knows neither HTTP nor the DB.
  • Repository: the persistence abstraction. Define it as an interface and make the implementation (PostgreSQL etc.) swappable.
  • DI (google/wire): resolve dependency wiring at compile time. Don't hold concretes directly in the handler (dependency inversion).
// Repository はインターフェースで定義(依存性逆転)
type UserRepository interface {
	Create(ctx context.Context, name, email string) (*User, error)
}

// UseCase は Repository に依存(具象 DB を知らない)
type UserUseCase struct {
	repo UserRepository
}

func (u *UserUseCase) Register(ctx context.Context, name, email string) (*User, error) {
	// バリデーション済み前提のドメインロジックだけ
	return u.repo.Create(ctx, name, email)
}

// Controller は UseCase に依存(HTTP の作法だけを知る)
type UserHandler struct {
	uc *UserUseCase
}

func (h *UserHandler) Create(c *echo.Context) error {
	var dto CreateUserDTO
	if err := c.Bind(&dto); err != nil {
		return echo.NewHTTPError(http.StatusBadRequest, "invalid request body")
	}
	if err := c.Validate(&dto); err != nil {
		return err
	}
	user, err := h.uc.Register(c.Request().Context(), dto.Name, dto.Email)
	if err != nil {
		return err
	}
	return c.JSON(http.StatusCreated, user)
}

The return of this separation is testability. Swap UserRepository for a mock, and the UseCase can be unit-tested exhaustively without a DB. Code-generate the wiring with google/wire, and hand-written dependency-injection mistakes disappear too. How to cut layers, dependency inversion, and how to use wire are dug into in the clean architecture + DI article; the Repository implementation (pgx/sqlc/GORM, transactions, connection pools) in the database-layer article; and auth/authorization (JWT issuance/validation, RBAC, refresh tokens) in the auth/authorization article.


10. Testing: a handler can be verified by "just passing a Context"

An Echo handler is a bare function receiving *echo.Context, so you can test it as-is with net/http/httptest. This is the source of "testability."

func TestUserHandler_Create(t *testing.T) {
	e := echo.New()
	e.Validator = &CustomValidator{validator: validator.New()}

	body := `{"name":"Hinata","email":"hinata@example.com"}`
	req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
	req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
	rec := httptest.NewRecorder()
	c := e.NewContext(req, rec)

	h := &UserHandler{uc: newUseCaseWithMockRepo()} // Repository はモック

	if assert.NoError(t, h.Create(c)) {
		assert.Equal(t, http.StatusCreated, rec.Code)
	}
}

v5 has the new echotest package, reducing boilerplate further.

import "github.com/labstack/echo/v5/echotest"

func TestUserHandler_Create_WithHelper(t *testing.T) {
	h := &UserHandler{uc: newUseCaseWithMockRepo()}

	rec := echotest.ContextConfig{
		Headers: map[string][]string{
			echo.HeaderContentType: {echo.MIMEApplicationJSON},
		},
		JSONBody: []byte(`{"name":"Hinata","email":"hinata@example.com"}`),
	}.ServeWithHandler(t, h.Create)

	assert.Equal(t, http.StatusCreated, rec.Code)
}

Path parameters can be specified with ContextConfig's PathValues, and forms and multipart with dedicated fields. The standard is to make go test ./... -race -cover mandatory in CI (GitHub Actions) and combine it with golangci-lint as a quality gate. The strategy through table-driven tests, mocks, and real-DB integration tests with testcontainers is systematized in the Echo testing-strategy article.


11. Deployment: ship minimal and safe with a multi-stage Docker

Go's strength is a single binary. With a multi-stage build, separate the "build environment" and the "runtime environment," and make the runtime image tiny and least-privileged.

# --- build stage ---
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# 静的リンク・デバッグ情報除去で軽量化
RUN CGO_ENABLED=0 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"]

There are three points. (1) Make it an image with neither a shell nor a package manager with distroless to cut the attack surface, (2) run as nonroot, (3) read the port, DB URL, and signing keys from environment variables (don't bake them into the image). Only combined with code that receives SIGTERM and stops gracefully do you get zero-downtime with rolling deploys. Production deployment including server timeouts (v5's BeforeServeFunc), health checks, and ECS/Cloud Run practice is detailed in the complete deployment guide.


12. Echo vs Gin vs Fiber vs net/http: Adoption Decision

"Should you use Echo" is decided by the project's constraints. By axes, not feel.

Aspectnet/http (standard)EchoGinFiber
FoundationStandardnet/httpnet/httpfasthttp (incompatible)
Learning costMedium (much written by hand)Low–mediumLowLow
MiddlewareSelf-madeConsistent built-in setBuilt-in + communityBuilt-in (Express-like)
Standard-ecosystem compatibility△ (http.Handler-incompatible)
Error handlingSelf-madeCentralized handler (strength)Self-made-ishCentralized handler
Suited sceneTiny, don't want to add dependenciesREST/JSON API quickly at production qualityExisting assets, the largest number of examplesRaw throughput top priority

The rule of thumb:

  • To build a REST/JSON business API at production quality, quickly, Echo is a good middle choice. Centralized error handling and middleware consistency pay off.
  • If you want to minimize dependencies or have a very small service, Go 1.22+'s enhanced net/http (method-pattern routing) alone may suffice.
  • If fasthttp's raw throughput is a requirement and you can tolerate standard-ecosystem incompatibility, Fiber. But the cost of not being able to use context.Context or http.Handler assets is large.

A fair comparison of Echo, Gin, net/http, and Fiber, and a per-use selection framework and migration policy, are dug into in the standalone article Echo vs Gin vs net/http thorough comparison.

I myself have adopted a configuration of Next.js for the front and Go/Echo for the backend. The design of placing OpenAPI as the contract and guaranteeing client⇄server type consistency at build time is detailed in the Next.js × Go × OpenAPI article.


Summary: 7 points to raise Echo to "production quality"

  1. Grasp the v5 diff first*echo.Context, slog, StartConfig. v4 samples won't run unmodified.
  2. Understand routing's priority (static→param→*) and bundle permission boundaries with groups.
  3. BindValidate input into a DTO and kill it at the boundary. Don't bind directly to business structs.
  4. Don't swallow errors—return them. Consolidate into consistent JSON and log emission with the centralized HTTPErrorHandler.
  5. Put Recover at the outermost and don't kill the process on a panic.
  6. StartConfig{GracefulTimeout} + SIGTERM to make rolling deploys zero-downtime.
  7. Keep handlers thin and testable with DI. Echo is the HTTP layer, logic goes to the UseCase.

Echo, precisely because it sits between "lightness" and "the parts production needs," is a framework where the designer's judgment directly shows in quality. With this article's pattern as a foundation, and the middleware and binding & validation & error design grasped, you reach an API that doesn't fall over, can be traced, and is easy to change.

友田

友田 陽大

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