Oct 28, 20267 min read

How to Deploy a Go API to AWS

Running a net/http Go API on AWS with RDS Postgres and a token in Secrets Manager, using Encore or ECS on Fargate.

Go compiles to a single static binary, which makes the container part of an AWS deploy simple. The time goes into what the binary talks to: an RDS database and its credentials, a secret for API tokens, IAM roles, migrations, and a Postgres for local development that matches production.

This guide deploys a small orders API written with the standard library net/http, whose ServeMux has matched methods and path wildcards since Go 1.22, so the routes need no third-party router. Orders live in Postgres, and creating one requires a bearer token kept in Secrets Manager. The same Go code is deployed with Encore and by hand on ECS Fargate.

What you need to decide:

  • How RDS and the secret are created and how the binary gets their values.
  • How migrations run before the new version takes traffic.
  • How the service shuts down without dropping requests.

Go API on AWS options at a glance

CapabilityEncoreECS on Fargate by hand
PostgresFrom sqldb.NewDatabase("orders", ...)aws rds create-db-instance plus networking
Secretsvar secrets struct{...}, set per environmentSecrets Manager plus task definition entries
MigrationsSQL files applied on deployA tool such as golang-migrate in a release step
ContainerBuilt by EncoreYour Dockerfile
IAMRole created on deployExecution role and task role by hand
Local setupencore run starts PostgresDocker Compose or a shared dev database

Encore: deploy a Go API to AWS from the code

Encore combines an open source infrastructure SDK for TypeScript and Go with a platform that deploys into your own AWS or GCP account. The handlers keep the net/http signature as raw endpoints, annotated with //encore:api, and the service declares its database and secret as package-level variables. Under encore run the database is a local Postgres; on deploy it is RDS.

package orders import ( "encoding/json" "errors" "net/http" "strconv" "time" "encore.dev" "encore.dev/rlog" "encore.dev/storage/sqldb" ) // RDS on AWS, and `encore run` starts Postgres locally. var db = sqldb.NewDatabase("orders", sqldb.DatabaseConfig{ Migrations: "./migrations", }) // Stored in AWS Secrets Manager, set with `encore secret set`. var secrets struct { OrdersApiToken string } type Order struct { ID int64 `json:"id"` Customer string `json:"customer"` TotalCents int64 `json:"totalCents"` CreatedAt time.Time `json:"createdAt"` } //encore:api public raw method=GET path=/healthz func Healthz(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) }

Creating an order compares the bearer token with the secret, and the queries use placeholders. Path parameters in raw endpoints come from encore.CurrentRequest():

//encore:api public raw method=POST path=/orders func CreateOrder(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Authorization") != "Bearer "+secrets.OrdersApiToken { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"}) return } var in struct { Customer string `json:"customer"` TotalCents int64 `json:"totalCents"` } if err := json.NewDecoder(r.Body).Decode(&in); err != nil || in.Customer == "" { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid body"}) return } var o Order err := db.QueryRow(r.Context(), `INSERT INTO orders (customer, total_cents) VALUES ($1, $2) RETURNING id, customer, total_cents, created_at`, in.Customer, in.TotalCents, ).Scan(&o.ID, &o.Customer, &o.TotalCents, &o.CreatedAt) if err != nil { rlog.Error("insert order", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal"}) return } writeJSON(w, http.StatusCreated, o) } //encore:api public raw method=GET path=/orders/:id func GetOrder(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(encore.CurrentRequest().PathParams.Get("id"), 10, 64) if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } var o Order err = db.QueryRow(r.Context(), `SELECT id, customer, total_cents, created_at FROM orders WHERE id = $1`, id, ).Scan(&o.ID, &o.Customer, &o.TotalCents, &o.CreatedAt) if errors.Is(err, sqldb.ErrNoRows) { writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"}) return } else if err != nil { rlog.Error("get order", "err", err) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal"}) return } writeJSON(w, http.StatusOK, o) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) }

The migration in migrations/1_create_orders.up.sql creates the table:

CREATE TABLE orders ( id BIGSERIAL PRIMARY KEY, customer TEXT NOT NULL, total_cents BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );

The local token goes in .secrets.local.cue as OrdersApiToken: "dev-token-789", which stays out of git. Run it and try the routes:

~/orders
$ encore run$ TOKEN=dev-token-789$ curl -X POST localhost:4000/orders -d '{}'{"error":"unauthorized"}$ curl -X POST localhost:4000/orders -H "Authorization: Bearer $TOKEN" \ -d '{"customer":"acme","totalCents":1999}'{"id":1,"customer":"acme","totalCents":1999,"createdAt":"2026-10-28T12:58:20.823867+01:00"}$ curl localhost:4000/orders/1{"id":1,"customer":"acme","totalCents":1999,"createdAt":"2026-10-28T12:58:20.823867+01:00"}$ curl localhost:4000/orders/abc{"error":"invalid id"}$ encore secret set --type production OrdersApiToken$ encore platform link --new$ git push encore

On deploy, Encore builds the binary into a container, provisions RDS Postgres with the migration applied and stores the token in Secrets Manager in your AWS account. Encore provisions an IAM role for the service. Each environment has its own database and secret values, and preview environments give each pull request a copy. The Go secrets docs and Go database docs cover per-environment values and connecting with other tools.

A coding agent can check a new table or secret against encore run before the push, and the AWS side follows from the same commit.

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

ECS on Fargate by hand

The manual version registers the same handlers on a ServeMux with Go 1.22 patterns such as "GET /orders/{id}" and r.PathValue("id"), swaps the SDK calls for pgx and environment variables, and handles SIGTERM itself, since ECS sends it before stopping a task:

ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt) defer stop() pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL")) if err != nil { log.Fatal(err) } srv := &http.Server{Addr: ":8080", Handler: mux} go func() { <-ctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel() srv.Shutdown(shutdownCtx) }() if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { log.Fatal(err) } pool.Close()

A multi-stage Dockerfile builds a static binary into a distroless image that runs as a non-root user:

FROM golang:1.27 AS build WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -o /orders . FROM gcr.io/distroless/static-debian12:nonroot COPY --from=build /orders /orders EXPOSE 8080 ENTRYPOINT ["/orders"]

Then the AWS resources. RDS can manage the master password in Secrets Manager for you, and the API token is a second secret:

~/orders
$ aws rds create-db-instance --db-instance-identifier orders-db \ --engine postgres --db-instance-class db.t4g.micro --allocated-storage 20 \ --master-username orders --manage-master-user-password \ --db-subnet-group-name private --vpc-security-group-ids sg-db --no-publicly-accessible$ aws secretsmanager create-secret --name orders/api-token --secret-string "$TOKEN"$ aws ecr create-repository --repository-name orders$ docker build -t $ECR/orders:1 . && docker push $ECR/orders:1

The ECS task definition maps both secrets into environment variables, which the execution role needs secretsmanager:GetSecretValue to resolve. The RDS-managed secret holds the password rather than a full URL, so the app builds DATABASE_URL from PGHOST, PGUSER and PGPASSWORD, or uses pgx's support for those variables directly. After that come the cluster, the service, an Application Load Balancer with a health check on /healthz, and security group rules from the tasks to RDS.

Good to know

Migrations need a separate step, usually a one-off ECS task running golang-migrate before the service updates. Keep the shutdown timeout below the ECS stop timeout, 30 seconds by default on Linux, so Shutdown finishes before SIGKILL. Many short-lived tasks opening connections can exhaust RDS limits, which RDS Proxy helps with at extra cost.

How to choose

Pick Encore when you want a Go service whose RDS database and secret come from the code, with an IAM role for the service provisioned on deploy, with the same Postgres under encore run and a copy of the stack per pull request, all in your own AWS account. The same service can add Pub/Sub and cron jobs that deploy alongside it.

Pick ECS by hand when your team already has Terraform modules for RDS and ECS, or when the service needs AWS settings you want to control line by line.

Frequently asked questions

How do I deploy a Go API to AWS?

Build a static binary into a small container image, push it to ECR and run it on ECS Fargate behind a load balancer, with RDS for Postgres and Secrets Manager for credentials. With Encore, declare sqldb.NewDatabase("orders", ...) and a secrets struct with an OrdersApiToken field in the Go code and push; Encore provisions RDS, the secret and the compute in your own AWS account.

Do I need a framework like Gin or Echo to build a Go API?

Not since Go 1.22. The standard library ServeMux matches methods and path wildcards, so patterns like "GET /orders/{id}" and r.PathValue("id") cover most REST routing without a third-party router.

How should a Go API shut down on ECS?

ECS sends SIGTERM and waits for the stop timeout, 30 seconds by default on Linux (see stopTimeout in the AWS ContainerDefinition API reference), before SIGKILL. Call http.Server.Shutdown on SIGTERM so in-flight requests finish, then close the database pool.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders