from docker push to HTTPS: deploying a Go server on ECS Fargate

awsfargateecsecrgolangelbroute53

I needed to deploy a Go HTTP server on AWS. Docker build, push, run — three commands on my machine. On AWS, those three commands turned into a maze of Task Definitions, Capacity Providers, Services, and Target Groups.

Here's what actually happened.


The Docker part is easy

If you know Docker, you already know 80% of this. Build. Tag. Push. The only difference is the registry URL.

docker build -t my-app .
docker tag my-app:latest 123456789.dkr.ecr.us-west-2.amazonaws.com/my-app:latest
docker push 123456789.dkr.ecr.us-west-2.amazonaws.com/my-app:latest

Two more steps to run it:

  1. Create a Task Definition in ECS — point it to 123456789.dkr.ecr.us-west-2.amazonaws.com/my-app:latest
  2. Launch a Service or Task — pick the task definition and run

Done. The rest of this article is about what went wrong between those steps.


The Go build that broke silently

A normal multi-stage Dockerfile:

FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -installsuffix cgo -o main .
 
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
COPY --from=builder /app/main .
CMD ["./main"]

Two flags matter here.

CGO_ENABLED=0

Go has its own implementations for low-level operations — networking, DNS, crypto. Setting CGO_ENABLED=0 tells the compiler: use those pure Go implementations. Don't link against the system's C library.

Without this, the binary depends on the exact glibc version in your build environment. Alpine uses musl, not glibc. The binary crashes at startup with a cryptic shared library error.

Trade-off: pure Go implementations are slightly larger but run anywhere. If your code needs C interop (SQLite, some graphics libraries), set it to 1 and match your base image's C library.

GOARCH=amd64

ECS Fargate only supports two architectures: X86_64 and ARM64. The default is X86_64.

"If the property is undefined, operatingSystemFamily is LINUX and cpuArchitecture is X86_64" — AWS CDK FargateServiceBaseProps

If you build on an M1/M2 Mac without specifying GOARCH, Go defaults to arm64 — because Go cross-compilation targets the host architecture by default. The image pushes to ECR fine. Fargate pulls it fine. Then:

exec ./main: exec format error

The binary is arm64, but Fargate's runtime is X86_64. Architecture mismatch. Specify GOARCH=amd64 (or arm64 if you chose ARM64 in Task Definition).

"When you register a task definition, you specify the CPU architecture. The valid values are X86_64 and ARM64." Default: X86_64Amazon ECS task definition parameters


Capacity Provider: why two things exist for "just pick Fargate"

When you create a Service, ECS asks: Launch Type or Capacity Provider Strategy?

Launch Type is simple. Pick Fargate, EC2, or Fargate Spot. Done.

Capacity Provider Strategy exists because production deployments need cost optimization. You mix Fargate (reliable, expensive) with Fargate Spot (cheap, might get interrupted). Two parameters control the mix:

  • base — guaranteed minimum tasks for one provider. One provider's base must be 0.
  • weight — ratio for distributing the remaining tasks.

Example: 12 tasks, Fargate base=3, Spot base=0, weights 1:2.

FargateFargate Spot
base30
weight12
result6 (3 + 3)6 (0 + 6)

First, Fargate gets its base of 3. Nine tasks remain. Weight 1:2 splits them 3 and 6. Fargate: 6 total. Spot: 6 total.

This matters for Services. Tasks don't auto-scale, so the strategy only affects initial placement.


Service or Task: the choice that confused me

I assumed Service and Task were fundamentally different things. They're not.

Tasks are the actual running units. Every container you run on ECS is a Task. Always.

Services are a management layer on top of Tasks. A Service says "keep N tasks running" and adds auto-scaling, load balancing, and rolling deployments.

When I created a Service with 3 replicas, I expected to see one Service. Instead, I saw one Service and three Tasks. The Service didn't replace Tasks — it created them.

ECSKubernetes equivalent
TaskPod
ServiceDeployment + Service
Task DefinitionPod spec

For a long-running HTTP server: Service. For a one-time batch job, CI/CD step, or cron: Task.

Here's what surprised me: the console lets you switch between Service and Task configurations freely. Choose "Run new Task," then change settings to look like a Service. The options are almost identical — Service just adds auto-scaling and ALB integration. The UI separation is for user clarity, not a technical boundary.

Even the Task Definition's launch type setting isn't binding. Define it as EC2, then launch it as Fargate. ECS doesn't enforce the match.


ALB: why you want it even for a single server

Without ALB, your task gets a public IP. That IP changes every time the task restarts. Users can't bookmark it. You can't add HTTPS.

ALB solves this through target groups. ECS registers tasks into a target group. ALB routes traffic to the target group. You expose one stable DNS name.

Multiple services, one ALB:

  • domain.app/api → target group backend
  • domain.app/ → target group landing-page

When you create a Service, the Load Balancing section lets you assign a target group. That's the registration step — ECS tells ALB "send traffic to these tasks."

From ALB to HTTPS with a custom domain

Three steps:

  1. Add an HTTPS listener on port 443. Attach your domain's ACM certificate.
  2. Set the HTTP listener on port 80 to redirect to HTTPS.
  3. In Route53, create an A record alias pointing your domain to the ALB's DNS name.

Custom domain. HTTPS. No certificates to manage on the server.

One trap hides here: if the load balancer returns 502 even though the task itself works, the target group's protocol is almost always the cause. I wrote that one up separately in why my load balancer returned 502.


What I'd do differently

Task Definition is to ECS what AMI is to EC2 — a blueprint. But Task Definition is simpler. An AMI captures an entire OS, processes, and configurations. A Task Definition just describes containers, resource limits, and environment variables.

The key difference: Task Definition must exist before you create a Service or Task. AMI is optional — you can launch an EC2 instance from AWS's default AMIs.

If I set this up again:

  1. Start with Capacity Provider Strategy, not Launch Type. Even if you only use Fargate today, the migration path to Spot is painless.
  2. Always specify GOARCH. Cross-compilation is free. Debugging format errors is not.
  3. Put ALB in front from day one. The cost is minimal. Retrofitting HTTPS and stable DNS later is a headache.
  4. Use Service, not Task, for anything that should stay running. The auto-restart alone is worth it.

References