Adding a Custom Domain and HTTPS to Your EC2 App

This follows up on Ship It, Run It Free for Months: Next.js + Django on AWS EC2. That guide leaves you with a real, working app reachable at http://YOUR_IP/ — Gunicorn and Next.js both alive under systemd, Nginx routing between them by path. This one picks up exactly there and adds the two things that guide deliberately skipped: a real domain name, and HTTPS.
Core points:
A custom domain and HTTPS cost you the price of the domain itself — everything that makes it work (DNS, certificates, the proxy config) is free.
The routing scheme changes from path-based (
/api/vs/) to hostname-based (api.yourapp.example.comvsyourapp.example.com) — cleaner, and it's what lets each subdomain get its own cookies and CORS rules later.Get the order of operations right here and it's boring; get it wrong (flipping a cookie flag before HTTPS is confirmed, or proxying DNS through Cloudflare before Certbot runs) and the symptoms look like unrelated bugs.
Contents
1. Attaching a real domain
In Cloudflare (or wherever the domain's DNS lives) → DNS → add two records, both pointed at the Elastic IP from the base guide's Step 2.3:
| Type | Name | Content | Proxy status |
|---|---|---|---|
| A | yourapp (or @ for the bare domain) |
YOUR_IP |
DNS only (grey cloud) |
| A | api |
YOUR_IP |
DNS only (grey cloud) |
Leave the cloud grey for now, not orange. Cloudflare's proxy is great once things work, but it also sits between the visitor and your server — which means Certbot in Step 3 can't reach your box directly to prove you own the domain. Grey means Cloudflare is acting as a plain DNS record right now, nothing more; the proxy comes back as an optional step once HTTPS is confirmed.
An A record is just a name pointing at an IP — that's the whole mechanism. Propagation usually takes seconds to a few minutes.
Check it landed:
dig +short yourapp.example.com
dig +short api.yourapp.example.com
Both should print YOUR_IP. If either comes back empty, give it a couple of minutes before assuming something's wrong — propagation delay is the most common false alarm here.
What this is standing in for: Cloudflare here is doing free DNS hosting. The managed AWS equivalent is Route 53 — a hosted zone costs ~\(0.50/month plus a small per-query fee, versus Cloudflare's \)0, but it's the natural choice if the rest of your infrastructure (ALB, ACM, CloudFront) is already AWS-native and you'd rather manage DNS in the same console. Neither is more "correct" — Cloudflare is simply free where Route 53 isn't.
2. Splitting Nginx by hostname
With both records resolving, replace the single server_name _; block from the base guide's Step 7 with two blocks, one per hostname:
sudo tee /etc/nginx/sites-available/myapp <<'EOF'
server {
listen 80;
server_name yourapp.example.com;
client_max_body_size 20M;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
}
server {
listen 80;
server_name api.yourapp.example.com;
client_max_body_size 20M;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
EOF
sudo nginx -t
sudo systemctl reload nginx
Nginx now picks the block by reading the Host header the browser sent — api.yourapp.example.com never touches the Next.js process, and vice versa. It's cleaner than the path-based routing from the base guide, and it's what lets each subdomain get treated completely independently later (its own cookies, its own CORS rules).
Now tell Django about the new hostname:
sudo sed -i 's/^DJANGO_ALLOWED_HOSTS=.*/DJANGO_ALLOWED_HOSTS=api.yourapp.example.com,localhost,127.0.0.1/' /etc/myapp/backend.env
sudo sed -i '/^CORS_ALLOWED_ORIGINS=/c\CORS_ALLOWED_ORIGINS=https://yourapp.example.com' /etc/myapp/backend.env
echo 'CSRF_TRUSTED_ORIGINS=https://yourapp.example.com,https://api.yourapp.example.com' | sudo tee -a /etc/myapp/backend.env
sudo systemctl restart myapp
If you skip this, Django will reject requests to the new hostname outright — and that's not a bug to route around. ALLOWED_HOSTS exists because without it, anyone could point a domain's DNS at your server's IP and trick both Django and your users into thinking that domain is legitimately yours. Rejecting hostnames it doesn't recognize is the protection working correctly; the fix is always to add the real hostname, never to turn the check off.
3. HTTPS
A certificate authority will only issue a certificate for a domain if you can prove you control it. Let's Encrypt (via Certbot) does that by asking your server to serve a specific token over plain HTTP, then checking it landed. That's exactly why grey-cloud DNS mattered back in Step 1 — if Cloudflare's proxy were already in front of the box, that check might never reach your Nginx, and issuance would fail.
sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d yourapp.example.com -d api.yourapp.example.com
Certbot edits the Nginx config itself — adds listen 443 ssl, points at the new certificate files, and redirects HTTP to HTTPS by default. It also sets up renewal on its own, since these certificates expire every 90 days.
What this is standing in for: Certbot + Let's Encrypt is the free way to get a certificate onto a box you control directly. The managed AWS equivalent is AWS Certificate Manager (ACM) — also free, but it only attaches to AWS-managed edges (an ALB or CloudFront), not directly to an EC2 box's Nginx. If you move to an ALB (see the path-based-routing note in the base guide), the certificate moves from Certbot/Nginx to ACM at the same time, and the 90-day renewal dance goes away entirely — ACM auto-renews as long as the ALB stays attached.
The cookie gotcha that looks like a login bug
Once HTTPS is live, flip the cookie flag and rebuild — remember, env changes need a rebuild to actually take effect (see the base guide's Step 6.3):
cd /home/ubuntu/yourapp/frontend
sed -i 's/COOKIE_SECURE=false/COOKIE_SECURE=true/' .env.production
npm run build
sudo systemctl restart myapp-frontend
If you get the order wrong here, the symptom is strange: login appears to work, then the page immediately bounces back to /login as if nothing happened. The cause is a Secure cookie — which browsers won't store or send over plain HTTP, by design — being set while the site is still on HTTP, or COOKIE_SECURE=true flipped before HTTPS was actually confirmed working. The fix is just the order above: confirm HTTPS first, then flip the flag, then rebuild.
4. The payoff
From your browser, check both are live with a padlock:
https://yourapp.example.com— the frontendhttps://api.yourapp.example.com/api/docs/— the API's Swagger docs
If both load with no certificate warning, the whole chain from the last three steps is actually working end to end — DNS, Nginx, systemd, the app, the database, on a real domain, encrypted.
Optional: harden it with Cloudflare's proxy
Now that HTTPS works directly against the origin, it's safe to flip those DNS records from grey cloud to orange (Proxied) — it hides your origin IP and adds Cloudflare's own DDoS protection and caching in front of everything. Pair it with SSL/TLS → Full (strict) in the Cloudflare dashboard, which requires a real certificate on the origin (which Certbot just gave you) instead of accepting a self-signed one. Don't leave SSL/TLS mode on Flexible long-term — it quietly reopens a plaintext hop between Cloudflare and your server that Full (strict) closes.
What this is standing in for: Cloudflare's proxy here is free CDN + DDoS protection sitting in front of a single origin. The managed AWS equivalent is CloudFront (as a CDN/edge cache) paired with AWS WAF (for the DDoS/bot-filtering side) — both paid, usage-based, and worth it mainly once traffic or attack surface actually justifies the cost. For a small app, Cloudflare's free tier covers the same ground for $0.
5. Lessons learned and cost addendum
What tripped me up, doing this exact walkthrough
These are the specific snags I personally hit setting this up — not a complete list. Your registrar, your DNS provider's defaults, or a Let's Encrypt rate-limit quirk on the day you run this can all throw something different.
Login that immediately kicks you back out (Step 3) — a
Securecookie set before HTTPS actually works. Confirm HTTPS first, flip the flag, then rebuild, in that order, every time.Certbot failing to issue a certificate — almost always Cloudflare's proxy (orange cloud) already turned on before Certbot ran. Set DNS to grey cloud, get the certificate, then flip to orange afterward if you want it.
DNS "not resolving" right after adding the record — usually just propagation delay of a minute or two, not a misconfigured record. Re-check with
digbefore assuming it's broken.
Hit something else? Paste the exact error into an AI assistant or search it online — the precise wording usually gets you to the cause fast.
What this adds to the base guide's cost
| Item | Approx. cost |
|---|---|
| Domain registration | Varies by TLD/registrar — typically $10–$20/year |
| Cloudflare DNS | $0 |
| Let's Encrypt certificates | $0, auto-renewing |
Everything from the base zero-cost guide stays the same price — this layer only adds the domain registration itself as a real recurring cost, and it's an annual one, not monthly.
Written from an actual deployment of this stack — the commands and gotchas above are the real ones hit along the way, not a hypothetical.



