Deploy to AWS EC2
A bare Linux box plus an Application Load Balancer doing TLS and host-based routing.
Teams already on AWS who want the standard production pattern there: ACM certificates, ALB routing, and everything inside a VPC they control.
A first deploy, or a solo project. You are assembling the load balancer, the certificate, three target groups and the DNS yourself, and the ALB alone costs more per month than most of the PaaS options.
Quick setup
- 1
Two security groups, not one
The instance group allows 8080/3000/3001 only from the ALB group — as a source, not an IP range. That is the single control keeping your containers off the public internet.
- 2
Size the instance for the build, not the traffic
Building two Next.js apps and a Go API back to back OOMs on t3.micro and t3.small. Start at t3.medium with 40 GB of gp3, and add swap.
sudo fallocate -l 2G /swapfilesudo chmod 600 /swapfilesudo mkswap /swapfile && sudo swapon /swapfile - 3
Adjust the Compose file for a bare VM
Drop the Traefik labels and the dokploy-network, then add host port mappings so the ALB target groups have something to reach. Postgres and Redis get no ports at all.
services:api:ports: ["8080:8080"]admin:ports: ["3001:3000"]web:ports: ["3000:3000"] - 4
One certificate, three hostnames
Request a single ACM certificate with all three hosts as subject alternative names, validated by DNS so it renews itself.
What catches people out
An unhealthy target group is almost always the health check path returning non-2xx, or the instance security group not allowing the ALB security group on that port.
depends_on: condition: service_completed_successfully works here unchanged — this is real Docker Compose on a real VM, so the migrate job needs no pre-deploy workaround.
There is no GitHub webhook on a bare instance. Either redeploy over SSH by hand or add a GitHub Actions workflow that does it for you.
If you do not need ALB features, a Caddy container on the instance does host routing and automatic certificates for nothing — see the Lightsail guide.
Deploying Sentex on AWS EC2
The example application below is called sentex and its domains are on gritcms.com. Substitute your own project name and hostnames as you go — every other detail, including the service layout and the Compose translation, applies unchanged to any Grit project.
Unlike Railway/Render/Fly, EC2 gives you a bare Linux box — there’s no platform-level "connect GitHub" or managed proxy. You run docker-compose.prod.yml almost exactly as-is (real Docker Compose, so depends_on: condition: service_completed_successfully for migrate works natively, same as the Dokploy/Coolify VPS guides), and you build the public-facing, TLS-terminating, host-based-routing layer yourself using an AWS Application Load Balancer (ALB) + ACM — the standard production pattern on AWS, and the reason this guide looks different from the plain-VPS ones.
Architecture at a glance
Internet → ALB (443, ACM cert, host-header routing) → EC2 instance (3 host ports)├─ :8080 → api container├─ :3001 → admin container└─ :3000 → web containerEC2 instance also runs postgres + redis, reachable only inside theinstance's own Docker network (no host port, no ALB route to them).
The ALB replaces Traefik/Dokploy’s role from the VPS guides: it terminates TLS with an AWS-managed certificate and routes each hostname to a different container port on the same instance.
Part 1 — Network and security groups
- In the VPC you’ll deploy into (the default VPC is fine for a first pass), confirm you have at least two public subnets in two different Availability Zones — the ALB requires this even though your EC2 instance only runs in one AZ.
- Create a security group
sentex-alb-sg:- Inbound: HTTP (80) from
0.0.0.0/0, HTTPS (443) from0.0.0.0/0. - Outbound: all traffic.
- Inbound: HTTP (80) from
- Create a security group
sentex-ec2-sg:- Inbound: SSH (22) from your IP only (not
0.0.0.0/0). - Inbound: custom TCP
8080,3000,3001— source:sentex-alb-sg(not an IP range). This is what keeps the app containers unreachable from the internet except through the ALB. - Outbound: all traffic.
- Inbound: SSH (22) from your IP only (not
Part 2 — Launch the EC2 instance
- EC2 → Launch Instance.
- AMI: Ubuntu Server 24.04 LTS.
- Instance type: t3.medium (2 vCPU / 4 GB) minimum — building three Docker images (two of them Next.js/pnpm builds) on
t3.micro/t3.smallroutinely OOMs. Scale up later once you know your real traffic. - Key pair: create or select one — you’ll need it for SSH.
- Network settings: the VPC/subnet from Part 1, auto-assign public IP: enabled (needed for SSH and for
git/docker pullegress; the app ports themselves stay locked to ALB-only per the security group). - Security group: attach
sentex-ec2-sg. - Storage: bump the root volume to at least 40 GB gp3 — three Docker images plus Postgres/Redis data adds up fast.
- Launch. Once running, allocate an Elastic IP and associate it to the instance, so the public IP doesn’t change on stop/start (useful for SSH convenience; the ALB, not this IP, is what your DNS will point at).
Part 3 — Install Docker on the instance
SSH in:
ssh -i /path/to/key.pem ubuntu@<instance-public-ip>
Install Docker Engine + Compose plugin:
curl -fsSL https://get.docker.com -o get-docker.shsudo sh get-docker.shsudo usermod -aG docker $USERnewgrp dockerdocker compose version # confirm the plugin is present
(Optional but recommended on t3.medium) add a swap file so pnpm/Next.js builds don’t get OOM-killed under memory pressure:
sudo fallocate -l 2G /swapfilesudo chmod 600 /swapfilesudo mkswap /swapfilesudo swapon /swapfileecho '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
Part 4 — Get the code and adjust the Compose file for a bare VM
- Clone the repo:git clone https://github.com/<you>/sentex.gitcd sentex
docker-compose.prod.ymlwas written for Dokploy’s Traefik + externaldokploy-network. Neither exists here — the ALB replaces Traefik, so make adocker-compose.ec2.ymlcopy with:- Remove the
traefik.*labels fromapi,admin,web(nothing is watching for them on a bare instance). - Remove the top-level
dokploy-networkentry and drop it from every service’snetworks:list — keep only the internalsentexnetwork. - Add host port mappings so the ALB’s target groups have something to hit.
services:api:ports:- "8080:8080"admin:ports:- "3001:3000"web:ports:- "3000:3000"- Remove the
postgresandredisget noports:entry — they stay reachable only via the internalsentexnetwork at hostnamespostgres/redis, exactly as your app’sPOSTGRES_HOST=postgres/REDIS_URLvalues already assume.- Everything else —
build:,env_file:,environment:,volumes:,depends_on:,healthcheck:, themigrateservice’scommand:andrestart: "no"— stays unchanged. Real Docker Compose on a real VM honorsservice_completed_successfullynatively, somigratestill runs to completion beforeapistarts, with no pre-deploy-command workaround needed.
Part 5 — Environment variables
- Create
.envin the repo root on the instance (do not commit it):nano .env - Paste the contents of your local
.env.production, withWEB_DOMAIN,ADMIN_DOMAIN,API_DOMAINset to the real hostnames:WEB_DOMAIN=sentex.gritcms.comADMIN_DOMAIN=admin.sentex.gritcms.comAPI_DOMAIN=api.sentex.gritcms.comPOSTGRES_USER=...POSTGRES_PASSWORD=...POSTGRES_DB=...THEME=atlasSOCIAL_AUTH_ENABLED=falseNEXT_PUBLIC_DEMO_LOGINS=falseR2_ACCOUNT_ID=...R2_ACCESS_KEY_ID=...R2_SECRET_ACCESS_KEY=...R2_BUCKET=...R2_ENDPOINT=... docker composereads.envin the working directory automatically and interpolates every${VARIABLE}in the Compose file — same mechanism the file’s ownenv_file: [.env]entries rely on forapi/migrate.
Part 6 — First deploy
docker compose -f docker-compose.ec2.yml --env-file .env up -d --build
Watch it come up:
docker compose -f docker-compose.ec2.yml logs -f migratedocker compose -f docker-compose.ec2.yml ps
Confirm migrate exits 0, then api, admin, web show as running.
Sanity-check locally on the instance before touching DNS/ALB:
curl -I http://localhost:8080/<health endpoint>curl -I http://localhost:3001curl -I http://localhost:3000
Part 7 — Request the TLS certificate (ACM)
- Open AWS Certificate Manager in the same region as your ALB will live.
- Request a certificate → Public certificate.
- Domain names — add all three as Subject Alternative Names on one certificate:sentex.gritcms.comadmin.sentex.gritcms.comapi.sentex.gritcms.com
- Validation method: DNS validation (faster and auto-renewing).
- ACM shows a CNAME record per domain. Create these at your DNS provider. If your zone is hosted in Route 53, ACM offers a Create records in Route 53 button that does this for you.
- Wait for all three domains to show Issued (usually a few minutes once the CNAMEs resolve).
Part 8 — Target groups
Create three target groups, type Instances, protocol HTTP, in the same VPC:
| Name | Port | Health check path |
|---|---|---|
| sentex-api-tg | 8080 | /<your api health endpoint> |
| sentex-admin-tg | 3001 | / |
| sentex-web-tg | 3000 | / |
For each: on the Register targets step, select your EC2 instance and, importantly, set the port override to that target group’s port (they all point at the same instance, just different ports) before clicking Include as pending below → Register pending targets.
Part 9 — Create the Application Load Balancer
- EC2 → Load Balancers → Create load balancer → Application Load Balancer.
- Scheme: Internet-facing. IP type: IPv4.
- VPC: same as the instance. Mappings: select the two+ public subnets from Part 1.
- Security group:
sentex-alb-sg. - Listeners:
- HTTP:80 — you’ll edit this after creation to redirect to HTTPS (step 7 below).
- HTTPS:443 — default certificate: the ACM cert from Part 7. Default action: pick any target group for now (e.g.
sentex-web-tg) — you’ll override per-hostname with rules next.
- Create the load balancer and wait for its state to become Active.
- On the HTTP:80 listener, edit the default rule to Redirect to HTTPS://#{host}:443/#{path}?#{query} (status 301) instead of forwarding — this makes plain-HTTP requests upgrade automatically.
- On the HTTPS:443 listener, add rules (in order, above the default action):
- IF Host header is
api.sentex.gritcms.com→ THEN forward tosentex-api-tg - IF Host header is
admin.sentex.gritcms.com→ THEN forward tosentex-admin-tg - IF Host header is
sentex.gritcms.com→ THEN forward tosentex-web-tg - Default action (no rule matched): forward to
sentex-web-tg, or return a fixed 404 — your call.
- IF Host header is
Part 10 — DNS
- Note the ALB’s DNS name (something like
sentex-alb-123456789.us-east-1.elb.amazonaws.com), shown on the load balancer’s detail page. - Create three records at your DNS provider:
- If hosted in Route 53: create A records with Alias target = the ALB, for all three hostnames (Alias records work for subdomains and are free of the CNAME-at-apex restriction, and Route 53 resolves them without an extra DNS lookup).
- If hosted elsewhere: create CNAME records for all three hostnames pointing at the ALB’s DNS name (fine here since none of the three is a bare apex domain).
- Wait for propagation, then confirm:dig +short api.sentex.gritcms.comdig +short admin.sentex.gritcms.comdig +short sentex.gritcms.com
Part 11 — Verify
- Visit
https://api.sentex.gritcms.com/<health endpoint>,https://admin.sentex.gritcms.com,https://sentex.gritcms.com— all should load over a valid ACM certificate with no browser warnings. - Open dev tools on
admin/web, confirm API calls succeed with no CORS errors (double checkCORS_ORIGINSin.envmatches exactly). - Log in with the seeded demo credentials to confirm
migrate/seedpopulated the database. - In the ALB console, confirm all three target groups show their registered target as healthy.
Part 12 — Ongoing deploys
There’s no GitHub webhook wired up out of the box on a raw EC2 box — pick one:
Manual (fine for a solo project):
ssh ubuntu@<instance-ip>cd sentex && git pulldocker compose -f docker-compose.ec2.yml --env-file .env up -d --build
GitHub Actions (push-to-deploy, closer to the other guides’ workflow):
name: Deploy to EC2on:push:branches: [main]jobs:deploy:runs-on: ubuntu-lateststeps:- uses: appleboy/ssh-action@v1with:host: ${{ secrets.EC2_HOST }}username: ubuntukey: ${{ secrets.EC2_SSH_KEY }}script: |cd sentexgit pulldocker compose -f docker-compose.ec2.yml --env-file .env up -d --build
Store the instance’s SSH private key as EC2_SSH_KEY and its Elastic IP as EC2_HOST in the repo’s GitHub Actions secrets.
migrate reruns on every up -d --build and is idempotent (per the Compose file’s own comments), so this is safe to run on every deploy.
Notes and troubleshooting
| Symptom | Likely cause |
|---|---|
| ALB target shows "unhealthy" | Health check path returns non-2xx, or the security group doesn’t allow the ALB SG on that port — recheck Part 1 step 3. |
| 504 from the ALB | Container isn’t listening on the port the target group expects — confirm with docker compose ps and curl localhost:<port> on the instance. |
| Build gets OOM-killed | Instance too small for concurrent Next.js builds — add swap (Part 3) or size up to t3.large temporarily for the first build. |
| CORS errors in browser | CORS_ORIGINS in .env doesn’t exactly match the live domains (scheme + host, no trailing slash) — redeploy api after fixing. |
| Cheaper alternative to the ALB | If you don’t need AWS-native routing/WAF/autoscaling, you can skip Parts 7–10 entirely and instead run a self-managed reverse proxy (e.g. Caddy) directly on the EC2 instance with automatic Let’s Encrypt certs — see the Lightsail guide’s Part 6 for the exact pattern, which works identically on a plain EC2 box. |
Platform dashboards and CLI flags change faster than these docs. For anything that looks different from what is written here, AWS EC2's own documentation is the authority: docs.aws.amazon.com/elasticloadbalancing/latest/application/introduction.html
