Daeseon Yoo
Back to project
·Troubleshoot·4 min

Deploying the backend on Vercel + AWS: RDS, S3, and every wall in order

First real backend deploy — Terraform-provisioned RDS (us-east-1) + a private S3 bucket, wired to the existing Vercel app by environment variable. The walls hit, in order: the Free-Tier backup limit, the RDS SSL handshake (sslmode=require is verify-full), Kakao KOE006 (missing platform domain), the KOE237 rate limit, and the OAuth account-linking surprise.

The app had been live on Vercel for the frontend + API, but with no real database — it ran on embedded PGlite, so data didn't survive a restart. This is the first deploy of the actual backend: AWS RDS for Postgres, S3 for media, wired to Vercel purely by environment variable. Region: us-east-1 (N. Virginia) instead of the originally-planned Seoul, since the target audience moved to North America.

Terraform, written by hand

I wrote the IaC from scratch to understand each piece rather than paste a module: provider (AWS, region from a variable), data lookups for the default VPC and its subnets, then resource blocks for a DB subnet group, a security group (inbound 5432 only), and the aws_db_instance (Postgres 16, db.t3.micro, 20 GB gp3, encrypted at rest). terraform initplanapply.

Wall 1 — Free-Tier backup limit

FreeTierRestrictionError: The specified backup retention period exceeds the maximum available to free tier customers.

backup_retention_period = 7 is too high for a Free-Tier account. Set it to 0 and the apply went through. Endpoint: beside-db.…us-east-1.rds.amazonaws.com.

S3 + least-privilege IAM

Added an aws_s3_bucket plus an aws_s3_bucket_public_access_block with all four flags on — fully private, no public object URLs (media is served via short-lived presigned URLs). Then a dedicated IAM user beside-s3-user with only AmazonS3FullAccess attached, and an access key for it — so the app's credentials can touch S3 and nothing else (blast radius limited if they leak), rather than reusing the root dev_user.

Secrets + Vercel env

Generated SESSION_SECRET and CRON_SECRET (crypto.randomBytes hex) and a VAPID keypair (web-push). Linked the repo (vercel link) and set every production env var — DATABASE_URL, S3_*, SESSION_SECRET, VAPID_*, CRON_SECRET, plus the existing Kakao/Google keys (unchanged from dev — OAuth keys are app-level, not per-domain). Redeployed with vercel --prod.

Wall 2 — the SSL handshake (the long one)

Login failed at the OAuth callback. First:

[kakao callback] no pg_hba.conf entry for host "98.92.91.124", user "beside_admin", database "beside", no encryption

DATABASE_URL had no sslmode, so pg connected in plaintext; RDS rejects that. Added ?sslmode=require. New error:

[kakao callback] Connection terminated unexpectedly
[google callback] Connection terminated unexpectedly

A generic message with no "why". Instead of guessing, I reproduced pg's own parsing locally — no production access:

require    => ssl = {}
no-verify  => ssl = {"rejectUnauthorized":false}

pg-connection-string even warns that require/prefer/verify-ca are treated as aliases for verify-full. So sslmode=require makes pg verify the RDS certificate against Node's built-in CA list — but RDS certs are signed by the Amazon RDS root CA, which isn't in that list → verification fails → the connection is terminated.

A second test showed that passing ssl: { rejectUnauthorized: false } in code does not help while the URL says sslmode=require: the connection string's sslmode clobbers the explicit option (the resolved ssl was still {}). So the connection string is the authoritative place to set this.

Fix: DATABASE_URL?sslmode=no-verify (encrypted in transit, skips CA verification). No code change. The stricter option, for later, is to bundle the Amazon RDS CA and use full verification.

Wall 3 — Kakao KOE006

Admin Settings Issue (KOE006)

The callback builds its redirect_uri from the request origin (lib/oauth/kakao.ts). Dev/localhost already worked, but in production the Kakao console was missing the Web platform site domain (https://beside.daeseon.ai) — registering only the Redirect URI isn't enough. Added the platform domain + the prod Redirect URI + turned Kakao Login on. Keys stayed the same (localhost and prod share one app). Google needed the parallel setup: an Authorized redirect URI, Authorized JavaScript origins, and an OAuth consent-screen test user (or publishing the app).

Wall 4 — KOE237 rate limit

kakao token exchange 429: ... "token request rate limit exceeded","error_code":"KOE237"

A friend hit this while testing. The callback exchanges the code exactly once per request, so it's not a loop — it's Kakao's per-app token rate limit. Every failed-at-DB attempt during the SSL debugging had still completed token exchange (consuming a request), a new app has a low quota, and a stray www/trailing-slash redirect would double each login's requests. It resets with time.

The OAuth account-linking surprise

Logging in with Kakao and then with Google produced two different, empty-looking boxes. Not a bug: establishUser keys users by auth_identifier, so kakao:<id> and google:<id> are two separate users with two separate boxes. There's no auto-link because the app deliberately collects no email (Kakao: id + nickname; Google: openid profile only), so there's no shared key to merge on. Real account linking — by verified email, or a manual "connect account" flow — is out of MVP scope.

Where it landed

Frontend on Vercel, Postgres on RDS (us-east-1), media on a private S3 bucket, all connected by environment variables — the app flips from embedded-PGlite-dev to real-infra-prod with no code change. Tables are created on first boot by the app's CREATE TABLE IF NOT EXISTS DDL, so there's no manual migration step. Login works end-to-end once the SSL mode and the Kakao platform domain are right.