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:
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:
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.
Run in your terminal to get started locally.
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:
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.
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.
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.
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.
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.
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.