Back to Resume
PrivateOctober 2024· 1 week

AWS VPC, ALB, API Gateway & RDS Migration Infrastructure

AWS Network and Database Migration for a Mobile Game Backend

RoleCloud Infrastructure Engineer

A mobile gaming company needed its backend moved onto AWS with a real network boundary between the public internet and its database. I built that network in Terraform: a two-AZ VPC, a bastion host, a private backend instance behind a load balancer and an API Gateway, and an RDS MySQL instance with generated credentials. I then migrated the company's existing production database, 25 tables of players, campaigns, and orders, into the new RDS instance over an SSH tunnel. The infrastructure is real and working end to end. The application sitting on it is a smoke-test Flask app that never got connected to that RDS instance.

The problem

A mobile gaming company had a live production MySQL database and needed a proper AWS network around it before building further. The database could not sit exposed on the internet. It needed a private subnet, a controlled way in for engineers, and a load-balanced path for the API layer to reach an application server. The naive answer, an RDS instance on a public subnet reachable directly, fails immediately: no separation between the internet and the data, no controlled entry point, and no repeatable way to stand the network back up. What they needed was infrastructure as code that gave them the standard shape, public and private subnets, a bastion for controlled access, a load balancer and gateway in front of the app, and their existing 25-table database moved in safely.

Constraints

  • ·An existing production database already held real player, campaign, and order data and had to move into the new RDS instance without ever exposing it publicly.
  • ·Single engineer: the six Terraform modules and the data migration had to stay manageable without a team.
  • ·Region-pinned to eu-central-1 throughout (provider.tf).
  • ·No managed migration tooling in scope, the database move ran as a manual, auditable command over a tunnel, not through a managed migration service.

Architecture

A two-AZ VPC with a public/private subnet split, fronted by an HTTP API Gateway and an ALB proxying to a single Flask instance in the private subnet, plus a separately provisioned RDS MySQL instance reachable only through a bastion host.

  • Client (external caller)
  • HTTP API Gateway (HTTP_PROXY routes to the ALB)
  • Application Load Balancer (HTTP :80 to target :8000)
  • Backend EC2 instance running the Flask app (private subnet)
  • data.csv (the app's actual data store)
  • Bastion host (public subnet, sole SSH entry point)
  • RDS MySQL instance with Secrets Manager + KMS-generated credentials (private subnet)
  • Legacy MySQL dump migrated into RDS over the bastion tunnel
  • ACM certificate module (written, commented out, no HTTPS listener)

The RDS instance and the running Flask app are not wired together in code. RDS holds a migrated legacy schema, the app reads and writes data.csv.

How one request flows

  1. 01Client calls the HTTP API Gateway's GET /data/{index} or POST /data route.
  2. 02API Gateway's HTTP_PROXY integration forwards the request to the ALB's DNS name on the same path, no transformation.
  3. 03The ALB's HTTP listener (port 80) forwards to a target group on port 8000, health-checked on /healthz.
  4. 04The Flask app on the single registered EC2 target reads or writes the requested row in a local data.csv file.
  5. 05The response flows back through the ALB and API Gateway to the client.
  6. 06Separately, offline, an engineer opens an SSH tunnel through the bastion host to reach the RDS instance directly for schema work or the original data import. The live request path above never touches it.

Engineering decisions

Two NAT gateways instead of one

What. Each of the two public subnets gets its own NAT gateway and Elastic IP, rather than a single shared NAT gateway for both private subnets.

Why. If one AZ's NAT gateway has an issue, the private subnet in the other AZ keeps outbound access.

Tradeoff. A single shared NAT gateway would have been cheaper. Two was chosen to avoid a single point of failure for outbound traffic across both AZs.

Bastion as the only way into the private subnets

What. A t3.small EC2 instance in a public subnet is the sole SSH path to the backend instance and the RDS instance.

Why. Keeps the backend instance and the database off any public IP address.

Tradeoff. The bastion's own security group is open in both directions rather than scoped to a fixed IP range or a VPN, a shortcut worth flagging rather than glossing over.

HTTP_PROXY passthrough at API Gateway, not a REST API with transformation

What. The HTTP API's two routes forward straight to the ALB's DNS name with HTTP_PROXY integrations, no request or response mapping.

Why. An HTTP API is cheaper than a REST API and this backend needed no transformation, just a pass-through.

Tradeoff. A REST API would allow request validation and mapping templates at the gateway layer. Not needed here because the ALB and Flask app already own that logic.

Generated RDS credentials, not hardcoded ones

What. A random_password resource generates the database password, stored as a Secrets Manager secret version behind a dedicated KMS key, and the RDS instance reads its password from that secret version.

Why. Keeps the password out of a Terraform variable file or a hardcoded string.

Tradeoff. The password still ends up in Terraform state as the instance's attribute, a known Terraform limitation this project does not solve on its own. It still beats a variable default anyone could read in version control.

Migrated the legacy database by hand, once

What. The prior production MySQL dump was loaded into RDS through an SSH tunnel via the bastion host, with a single mysql ... < dump.sql command.

Why. It was a one-time move of an existing database, not a repeating job, so a manual, auditable command was enough.

Tradeoff. A managed migration service would suit a recurring or larger sync. For a single 25-table load, the manual tunnel was faster to run and easier to verify by hand.

The application was never connected to the migrated database

What. app.py reads and writes a local data.csv file. It has no MySQL client, no connection string, and no code path to the RDS instance sitting in the same VPC.

Why. This is the honest state of the delivered code, not a design choice with a rationale to defend.

Tradeoff. Closing this gap would mean adding a database layer to app.py and pointing it at the RDS credentials already sitting in Secrets Manager. That work was out of scope for this engagement and is called out here rather than implied.

HTTPS was staged, not delivered

What. modules/certificate defines a working ACM certificate and Route53 validation, but main.tf has the module commented out and the ALB has only an HTTP listener.

Why. The certificate module was prepared for exactly this project, then priorities moved before it got wired in.

Tradeoff. Turning it on later is small work: uncomment the module in main.tf, wire the certificate_arn into an HTTPS listener in modules/alb, and enable the redirect block already sketched but commented out.

What changed along the way

  • A certificate module for HTTPS was written in full (ACM + Route53 validation) but never wired into main.tf, so the ALB stayed HTTP-only.
  • The client's own ProjectDetails.md described a broader scope (a Flutter frontend, a separate Node.js backend, AWS WAF, AWS Shield). None of it materialized in this delivery, what shipped was the AWS network, RDS, and a smoke-test Flask app.
  • The Flask app was never connected to the RDS instance it sits next to. It kept reading and writing data.csv through to the end of this engagement.

Security and reliability

  • ·Single controlled entry point: the bastion host is the only way into the private subnets. The backend instance and the RDS instance are not directly reachable from the internet.
  • ·RDS credentials generated by random_password and stored in Secrets Manager behind a dedicated KMS key, not hardcoded.
  • ·No HTTPS: the ALB listener is HTTP only. The certificate module exists but is commented out.
  • ·No application-level authentication on the Flask API, at either the ALB or the API Gateway layer.
  • ·Health-checked target: the ALB checks /healthz before forwarding traffic to the backend instance.

Metrics

6
Terraform modules composing the network
measured
25
tables migrated from the legacy MySQL dump
measured
0
app-to-RDS connections in the delivered application code
measured

What shipped

  • A working two-AZ VPC with public/private subnets, NAT gateways, and per-tier security groups.
  • A bastion host providing SSH access to the private backend EC2 instance and the private RDS instance.
  • An ALB and HTTP API Gateway in front of a Flask backend, proxying GET/POST /data end to end.
  • An RDS MySQL instance with Secrets-Manager-held, KMS-encrypted, randomly generated credentials.
  • The existing 25-table game/campaign/player production schema imported into the new RDS instance via the bastion tunnel.

Lessons learned

  • ·Infrastructure and the application it hosts should ship together, or the integration gap between them becomes invisible technical debt.
  • ·Generating database credentials through Secrets Manager beats hardcoding them, even on a small, single-engineer project.
  • ·An SSH tunnel through a bastion host is enough to reach a private RDS instance safely. There is rarely a good reason to expose it directly.
  • ·A module that is written but not wired in, like the certificate module here, should be labeled staged, not claimed as delivered.

Tech stack

Backend
Terraform (six modules)AWS VPCNAT gatewaysApplication Load BalancerHTTP API GatewayAmazon EC2 (bastion + backend instance)
Data & state
Amazon RDS MySQL 8.0AWS Secrets ManagerAWS KMSrandom_password generationlocal CSV file (application data store)
Ops & quality
Manual terraform applymanual SSH-tunnel database migrationmanual smoke-test scripts (get.py, post.py)