<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>Forem: Tirth T</title>
    <description>The latest articles on Forem by Tirth T (@tirtht).</description>
    <link>https://forem.com/tirtht</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3778837%2F8269dac2-f4db-46b7-abed-70f659524bf1.png</url>
      <title>Forem: Tirth T</title>
      <link>https://forem.com/tirtht</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/tirtht"/>
    <language>en</language>
    <item>
      <title>[Boost]</title>
      <dc:creator>Tirth T</dc:creator>
      <pubDate>Thu, 12 Mar 2026 11:04:28 +0000</pubDate>
      <link>https://forem.com/tirtht/-17e5</link>
      <guid>https://forem.com/tirtht/-17e5</guid>
      <description>&lt;div class="ltag__link"&gt;
  &lt;a href="/tirtht" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__pic"&gt;
      &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3778837%2F8269dac2-f4db-46b7-abed-70f659524bf1.png" alt="tirtht"&gt;
    &lt;/div&gt;
  &lt;/a&gt;
  &lt;a href="https://dev.to/tirtht/i-moved-my-database-behind-a-vpn-on-aws-heres-every-step-with-the-networking-concepts-that-4cj0" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__content"&gt;
      &lt;h2&gt;I Moved My Database Behind a VPN on AWS — Here's Every Step (With the Networking Concepts That Actually Matter)&lt;/h2&gt;
      &lt;h3&gt;Tirth T ・ Mar 12&lt;/h3&gt;
      &lt;div class="ltag__link__taglist"&gt;
        &lt;span class="ltag__link__tag"&gt;#aws&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#security&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#beginners&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#networking&lt;/span&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/a&gt;
&lt;/div&gt;


</description>
      <category>aws</category>
      <category>security</category>
      <category>beginners</category>
      <category>networking</category>
    </item>
    <item>
      <title>I Moved My Database Behind a VPN on AWS — Here's Every Step (With the Networking Concepts That Actually Matter)</title>
      <dc:creator>Tirth T</dc:creator>
      <pubDate>Thu, 12 Mar 2026 11:04:10 +0000</pubDate>
      <link>https://forem.com/tirtht/i-moved-my-database-behind-a-vpn-on-aws-heres-every-step-with-the-networking-concepts-that-4cj0</link>
      <guid>https://forem.com/tirtht/i-moved-my-database-behind-a-vpn-on-aws-heres-every-step-with-the-networking-concepts-that-4cj0</guid>
      <description>&lt;h2&gt;
  
  
  The Moment I Realized Our Database Was Naked
&lt;/h2&gt;

&lt;p&gt;I was auditing our AWS security groups when I saw this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Security Group: sg-0139b9d03b7d72f11
Inbound Rules:
  MySQL/Aurora  3306  0.0.0.0/0    ← THE ENTIRE INTERNET
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our production MySQL RDS — the one with real user data — was accessible from &lt;strong&gt;every IP address on the planet&lt;/strong&gt;. The only thing standing between an attacker and our database was a username and password.&lt;/p&gt;

&lt;p&gt;No VPN. No private subnet. No network-level isolation. Just vibes and a MySQL password.&lt;/p&gt;

&lt;p&gt;If you're reading this and thinking &lt;em&gt;"wait, let me check mine..."&lt;/em&gt; — yeah, go check. I'll wait.&lt;/p&gt;




&lt;h2&gt;
  
  
  What We're Building
&lt;/h2&gt;

&lt;p&gt;By the end of this guide, your architecture goes from this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BEFORE :

  Anyone on the Internet
       │
       │  Direct connection, port 3306 wide open
       ▼
  MySQL RDS (Public IP, 0.0.0.0/0)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AFTER :

  Developer Laptop
       │
       │ 1. Connect to VPN (encrypted tunnel)
       ▼
  OpenVPN Server (EC2, public subnet)
       │
       │ 2. NAT translates VPN IP → VPC IP
       ▼
  MySQL RDS (private subnet, NO public IP, NO internet route)
       ▲
       │ 3. Backend servers connect directly (same VPC)
  Elastic Beanstalk / ECS / EC2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What you'll learn:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What actually makes a subnet "private" (it's one missing route)&lt;/li&gt;
&lt;li&gt;How PKI (Public Key Infrastructure) works — the certificate system behind HTTPS, Kubernetes, and now your VPN&lt;/li&gt;
&lt;li&gt;How Linux routes packets between networks (IP forwarding + NAT)&lt;/li&gt;
&lt;li&gt;How to set up OpenVPN from scratch on Amazon Linux 2023&lt;/li&gt;
&lt;li&gt;Every command explained — no "just run this and trust me"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What you'll need:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An AWS account&lt;/li&gt;
&lt;li&gt;An existing VPC (we'll use the default one — you already have it)&lt;/li&gt;
&lt;li&gt;~30 minutes&lt;/li&gt;
&lt;li&gt;Basic terminal comfort&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Why OpenVPN? (And Not Tailscale or WireGuard)
&lt;/h2&gt;

&lt;p&gt;Quick honest comparison before we start:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;OpenVPN&lt;/th&gt;
&lt;th&gt;WireGuard&lt;/th&gt;
&lt;th&gt;Tailscale&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup time&lt;/td&gt;
&lt;td&gt;1-2 hr&lt;/td&gt;
&lt;td&gt;30 min&lt;/td&gt;
&lt;td&gt;5 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Protocol&lt;/td&gt;
&lt;td&gt;TCP + UDP&lt;/td&gt;
&lt;td&gt;UDP only&lt;/td&gt;
&lt;td&gt;UDP only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auth model&lt;/td&gt;
&lt;td&gt;Full PKI (certificates)&lt;/td&gt;
&lt;td&gt;Simple key pairs&lt;/td&gt;
&lt;td&gt;SSO/OAuth&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Learning value&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Very high&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;Low&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;What you own&lt;/td&gt;
&lt;td&gt;Everything&lt;/td&gt;
&lt;td&gt;Everything&lt;/td&gt;
&lt;td&gt;Tailscale owns the coordination&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I chose OpenVPN because:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;TCP support&lt;/strong&gt; — WireGuard is UDP-only. Some corporate networks and hotel WiFi block non-standard UDP. OpenVPN over TCP on port 443 looks like HTTPS traffic to firewalls. It Just Works everywhere.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;PKI is a career skill&lt;/strong&gt; — The certificate infrastructure you'll build here is the same concept behind Kubernetes mTLS, HTTPS, zero-trust architectures, and enterprise security. Understanding it once unlocks understanding everywhere.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Full ownership&lt;/strong&gt; — No third-party coordination server. No account. No vendor. Just your CA, your certs, your server.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want "it works in 5 minutes and I don't care how," use Tailscale. Seriously, it's great. But if you want to &lt;em&gt;understand&lt;/em&gt; networking, keep reading.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 1: Build the Private Network Layer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The One Thing That Makes a Subnet Private
&lt;/h3&gt;

&lt;p&gt;Here's the secret nobody explains clearly: &lt;strong&gt;a subnet is private because its route table doesn't have a route to the Internet Gateway.&lt;/strong&gt; That's it. That's the whole thing.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Public subnet route table:
  172.31.0.0/16  → local        (VPC internal traffic)
  0.0.0.0/0      → igw-xxxxx    (everything else → internet)  ← THIS makes it public

Private subnet route table:
  172.31.0.0/16  → local        (VPC internal traffic)
                                  ← NO igw route = private
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No magic. No special "private" checkbox on the subnet itself. Just a missing route.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;local&lt;/code&gt; route means "any traffic for 172.31.x.x stays inside the VPC." Since there's no route for anything else (0.0.0.0/0), packets to/from the internet simply have nowhere to go. But — critically — your backend servers in other VPC subnets CAN still reach this private subnet because of that &lt;code&gt;local&lt;/code&gt; route.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1.1: Create Two Private Subnets
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Why two?&lt;/strong&gt; RDS requires a "subnet group" spanning at least 2 Availability Zones, even for single-AZ deployments. It's an AWS requirement.&lt;/p&gt;

&lt;p&gt;Go to &lt;strong&gt;VPC Console → Subnets → Create subnet:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Subnet A:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VPC:               Your VPC (e.g., vpc-0d8e4d1.....)
Name:              private-db-1a
Availability Zone: ap-south-1a (or your preferred AZ)
IPv4 CIDR:         172.31.48.0/20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Subnet B:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;VPC:               Same VPC
Name:              private-db-1b
Availability Zone: ap-south-1b
IPv4 CIDR:         172.31.64.0/20
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Choosing the CIDR:&lt;/strong&gt; If your existing subnets use .0, .16, and .32 ranges (default VPC), the next available /20 blocks are .48 and .64. Each /20 gives you 4,096 IPs — way more than needed, but it's the standard block size.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For both subnets, go to &lt;strong&gt;Actions → Edit subnet settings&lt;/strong&gt; and make sure &lt;strong&gt;"Enable auto-assign public IPv4 address"&lt;/strong&gt; is &lt;strong&gt;unchecked&lt;/strong&gt;. Belt and suspenders.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1.2: Create the Private Route Table
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;VPC Console → Route tables → Create route table:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name: rt-private-db
VPC:  Your VPC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After creation, verify the &lt;strong&gt;Routes&lt;/strong&gt; tab shows ONLY:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Destination      Target
172.31.0.0/16    local
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No &lt;code&gt;0.0.0.0/0 → igw-xxxxx&lt;/code&gt;. That's the whole point.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1.3: Associate Your Subnets
&lt;/h3&gt;

&lt;p&gt;On the route table page → &lt;strong&gt;Subnet associations → Edit → Check ONLY your two private subnets → Save.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Without this, your subnets default to the Main route table (which has the IGW route, making them public). Explicit association is required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1.4: Create the RDS Subnet Group
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;RDS Console → Subnet groups → Create:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name:        my-private-db-subnet
Description: Private subnets for RDS - no internet access
VPC:         Your VPC
AZs:         Select both AZs
Subnets:     Select ONLY your two private subnets
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 1.5: Launch Your RDS in the Private Subnet
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;RDS Console → Create database:&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The key settings:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Why it matters&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;DB subnet group&lt;/td&gt;
&lt;td&gt;&lt;code&gt;my-private-db-subnet&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;This is THE setting&lt;/strong&gt; — places RDS in private subnets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Public access&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;No public IP assigned&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VPC security group&lt;/td&gt;
&lt;td&gt;Create new: &lt;code&gt;private-db-sg&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;We'll configure this next&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For everything else (engine, instance class, storage) — use whatever matches your needs. I'm using MySQL 8.0 on db.t3.micro for this guide.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1.6: Configure the DB Security Group
&lt;/h3&gt;

&lt;p&gt;This is where you define WHO can talk to your database. Edit the inbound rules of &lt;code&gt;private-db-sg&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;3306&lt;/td&gt;
&lt;td&gt;Your backend's security group ID&lt;/td&gt;
&lt;td&gt;Backend servers connect via SG reference&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3306&lt;/td&gt;
&lt;td&gt;VPN server's security group ID&lt;/td&gt;
&lt;td&gt;Developer access (we'll create this SG in Phase 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Why reference security groups instead of IPs?&lt;/strong&gt; Because your backend IPs can change (scaling events, deployments, instance replacements). The security group stays the same. It's the AWS-native way to say "allow traffic from any EC2 that has this role."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Notice what's NOT here:&lt;/strong&gt; No &lt;code&gt;0.0.0.0/0&lt;/code&gt;. No random developer IPs. No "I'll fix it later" rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  Verify: Can You Reach the Database?
&lt;/h3&gt;

&lt;p&gt;From your laptop (no VPN yet):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Windows:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Test-NetConnection&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-ComputerName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;your-db.xxxx.region.rds.amazonaws.com&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Port&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;3306&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# Expected: TcpTestSucceeded : False&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="c"&gt;# Linux/Mac:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;nc&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-zv&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;your-db.xxxx.region.rds.amazonaws.com&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;3306&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-w&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;5&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# Expected: Connection timed out&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Good.&lt;/strong&gt; The database is unreachable from the internet. That's what we want. Now we need a way IN. (You can also try other commands like telnet if you have those installed , these are most simplest ones. )&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 2: Launch the OpenVPN Server
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 2.1: Create the VPN Security Group
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;EC2 Console → Security Groups → Create:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name:  vpn-server-sg
VPC:   Your VPC
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inbound rules:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1194/TCP&lt;/td&gt;
&lt;td&gt;0.0.0.0/0&lt;/td&gt;
&lt;td&gt;VPN clients connect from anywhere&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;22/TCP&lt;/td&gt;
&lt;td&gt;YOUR-IP/32&lt;/td&gt;
&lt;td&gt;SSH admin access, your IP only&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;"Wait, 0.0.0.0/0 on the VPN port? Isn't that what we're trying to avoid?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Good instinct. But it's safe here because OpenVPN is protected by &lt;strong&gt;PKI certificates&lt;/strong&gt; — without a valid signed certificate, you can't even start a handshake. Plus we'll add HMAC authentication (ta.key) that drops unsigned packets before any TLS processing. It's not like an open MySQL port where a username/password is your only defense.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SSH is restricted to your IP&lt;/strong&gt; because SSH uses password/key auth — no certificate layer. Keep it locked down.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2.2: Add VPN SG to the DB Security Group
&lt;/h3&gt;

&lt;p&gt;Go back to &lt;code&gt;private-db-sg&lt;/code&gt; and add:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MySQL/Aurora  3306  from vpn-server-sg  "Developer access via VPN"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This completes the security chain: VPN clients → OpenVPN EC2 (has &lt;code&gt;vpn-server-sg&lt;/code&gt;) → NAT → RDS (accepts traffic from &lt;code&gt;vpn-server-sg&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2.3: Launch the EC2 Instance
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;EC2 Console → Launch instance:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Value&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AMI&lt;/td&gt;
&lt;td&gt;Amazon Linux 2023 (ARM)&lt;/td&gt;
&lt;td&gt;AWS-optimized, Graviton support&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Type&lt;/td&gt;
&lt;td&gt;t4g.micro&lt;/td&gt;
&lt;td&gt;ARM = cheaper. Micro handles &amp;lt;20 VPN connections easily&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Subnet&lt;/td&gt;
&lt;td&gt;A &lt;strong&gt;public&lt;/strong&gt; subnet&lt;/td&gt;
&lt;td&gt;VPN server MUST be reachable from the internet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Auto-assign public IP&lt;/td&gt;
&lt;td&gt;Enable&lt;/td&gt;
&lt;td&gt;Needs internet reachability&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security group&lt;/td&gt;
&lt;td&gt;&lt;code&gt;vpn-server-sg&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The one we just created&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Storage&lt;/td&gt;
&lt;td&gt;8 GB gp3&lt;/td&gt;
&lt;td&gt;Minimal, sufficient&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Step 2.4: Attach an Elastic IP
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;EC2 Console → Elastic IPs → Allocate → Associate with your instance.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Without an Elastic IP, the public IP changes every time you stop/start the instance. Every developer's VPN config has the server IP hardcoded — you don't want to redistribute configs every reboot.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Elastic IPs are free while attached to a running instance. They cost money if allocated but NOT attached (AWS charges for wasted IPs).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 2.5: SSH In
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"your-key.pem"&lt;/span&gt; ec2-user@YOUR-ELASTIC-IP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Amazon Linux uses &lt;code&gt;ec2-user&lt;/code&gt;. Ubuntu uses &lt;code&gt;ubuntu&lt;/code&gt;. Using the wrong username = "Permission denied."&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Phase 3: Build the PKI (Your Own Certificate Authority)
&lt;/h2&gt;

&lt;p&gt;This is where it gets interesting. We're building the same kind of certificate infrastructure that powers HTTPS across the entire internet — but for our VPN.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is PKI and Why Should You Care?
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Real-world analogy:

  Government (Certificate Authority)
       │
       ├── Issues passport to "VPN Server"
       │   └── Clients verify: "Is this the real server?"
       │
       ├── Issues passport to "Developer Tirth"
       │   └── Server verifies: "Is this an authorized developer?"
       │
       └── Revokes passport of "Developer Who Left"
           └── Their passport is now useless, even if they still have it
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;PKI gives us three superpowers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Mutual authentication&lt;/strong&gt; — server proves identity to client, client proves identity to server&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Individual revocation&lt;/strong&gt; — fire someone? Revoke their cert. Everyone else keeps working&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No shared secrets&lt;/strong&gt; — each developer has a unique key pair. Compromising one doesn't compromise others&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Step 3.1: Install Packages
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Update system&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf update &lt;span class="nt"&gt;-y&lt;/span&gt;

&lt;span class="c"&gt;# OpenVPN is in default Amazon Linux 2023 repos (no EPEL needed!)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; openvpn iptables-services

&lt;span class="c"&gt;# EasyRSA is NOT in the repos — download from GitHub&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; ~
curl &lt;span class="nt"&gt;-LO&lt;/span&gt; https://github.com/OpenVPN/easy-rsa/releases/download/v3.2.2/EasyRSA-3.2.2.tgz
&lt;span class="nb"&gt;tar &lt;/span&gt;xzf EasyRSA-3.2.2.tgz
&lt;span class="nb"&gt;mv &lt;/span&gt;EasyRSA-3.2.2 openvpn-ca
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The EPEL trap:&lt;/strong&gt; Many guides tell you to install EPEL on Amazon Linux 2023. It fails with &lt;code&gt;nothing provides redhat-release &amp;gt;= 9&lt;/code&gt;. Amazon Linux 2023 isn't RHEL 9 — it's Fedora-based but with its own package set. OpenVPN is already in the default repos. EasyRSA we grab manually.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;What is EasyRSA?&lt;/strong&gt; It's a wrapper around OpenSSL. OpenSSL can do everything EasyRSA does, but with commands like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Without EasyRSA (raw OpenSSL):&lt;/span&gt;
openssl req &lt;span class="nt"&gt;-new&lt;/span&gt; &lt;span class="nt"&gt;-keyout&lt;/span&gt; server.key &lt;span class="nt"&gt;-out&lt;/span&gt; server.req &lt;span class="nt"&gt;-nodes&lt;/span&gt; &lt;span class="nt"&gt;-subj&lt;/span&gt; &lt;span class="s2"&gt;"/CN=server"&lt;/span&gt;
openssl ca &lt;span class="nt"&gt;-in&lt;/span&gt; server.req &lt;span class="nt"&gt;-out&lt;/span&gt; server.crt &lt;span class="nt"&gt;-config&lt;/span&gt; openssl.cnf &lt;span class="nt"&gt;-batch&lt;/span&gt;

&lt;span class="c"&gt;# With EasyRSA:&lt;/span&gt;
./easyrsa gen-req server nopass
./easyrsa sign-req server server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same result. 10x simpler.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3.2: Enable IP Forwarding
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Enable NOW (takes effect immediately)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;-w&lt;/span&gt; net.ipv4.ip_forward&lt;span class="o"&gt;=&lt;/span&gt;1

&lt;span class="c"&gt;# Make it survive reboots&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'net.ipv4.ip_forward = 1'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/99-openvpn.conf
&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why?&lt;/strong&gt; By default, Linux drops packets that aren't addressed to itself. But VPN clients will send packets destined for the RDS (172.31.48.x), and they arrive at the VPN server (172.31.33.x). Without forwarding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Without IP forwarding:
  Packet from 10.8.0.2 → destination 172.31.48.50
  Server: "That's not my IP. DROPPED."

With IP forwarding:
  Packet from 10.8.0.2 → destination 172.31.48.50
  Server: "Not mine, but I'll forward it." → routes to RDS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 3.3: Build the Certificate Authority
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/openvpn-ca
./easyrsa init-pki
./easyrsa build-ca nopass
&lt;span class="c"&gt;# When prompted for Common Name, enter something meaningful:&lt;/span&gt;
&lt;span class="c"&gt;# "MyCompany VPN CA"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates two files that are the root of your entire security:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;What&lt;/th&gt;
&lt;th&gt;If compromised&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pki/ca.crt&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CA certificate (public)&lt;/td&gt;
&lt;td&gt;Not a disaster — it's meant to be shared&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pki/private/ca.key&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;CA private key&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Game over.&lt;/strong&gt; Attacker can forge any certificate. Rebuild everything.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Back up &lt;code&gt;ca.key&lt;/code&gt; NOW.&lt;/strong&gt; Store it encrypted somewhere safe. If you lose it, you can't sign new certs or revoke old ones. You'd rebuild the entire PKI from scratch.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 3.4: Generate Server Certificate
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Generate key + certificate signing request&lt;/span&gt;
./easyrsa gen-req vpn-server nopass

&lt;span class="c"&gt;# Sign it with your CA (type "yes" when prompted)&lt;/span&gt;
./easyrsa sign-req server vpn-server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;server&lt;/code&gt; type in &lt;code&gt;sign-req server&lt;/code&gt; embeds &lt;code&gt;extendedKeyUsage = serverAuth&lt;/code&gt; in the certificate. This means a client certificate can't impersonate the server, and a server certificate can't be used as a client. Cryptographic separation of roles.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3.5: Generate DH Parameters + TLS-Auth Key
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# DH parameters (takes 1-5 minutes — it's finding large primes)&lt;/span&gt;
./easyrsa gen-dh

&lt;span class="c"&gt;# TLS-Auth HMAC key&lt;/span&gt;
openvpn &lt;span class="nt"&gt;--genkey&lt;/span&gt; secret ta.key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;DH (Diffie-Hellman):&lt;/strong&gt; Allows client and server to agree on a shared encryption key without ever transmitting it. The math is beautiful — both sides compute the same secret independently, and an eavesdropper who sees the entire exchange still can't figure it out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ta.key:&lt;/strong&gt; An HMAC key that signs every single packet. If a packet arrives without a valid HMAC signature, it's dropped &lt;strong&gt;immediately&lt;/strong&gt; — before any TLS processing, before any CPU-expensive crypto. This gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DoS protection (attackers can't overwhelm TLS handshakes)&lt;/li&gt;
&lt;li&gt;Port scan invisibility (port 1194 appears closed to scanners)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 3.6: Generate Your First Client Certificate
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./easyrsa gen-req dev-yourname nopass
./easyrsa sign-req client dev-yourname
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Per-developer certificates are non-negotiable.&lt;/strong&gt; If a developer leaves:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;With shared cert: revoke it → regenerate → redistribute to EVERYONE → downtime&lt;/li&gt;
&lt;li&gt;With per-developer certs: &lt;code&gt;./easyrsa revoke dev-departed&lt;/code&gt; → done. Nobody else affected.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Step 3.7: Deploy Certs to OpenVPN Directory
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo cp&lt;/span&gt; ~/openvpn-ca/pki/ca.crt /etc/openvpn/server/
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; ~/openvpn-ca/pki/issued/vpn-server.crt /etc/openvpn/server/
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; ~/openvpn-ca/pki/private/vpn-server.key /etc/openvpn/server/
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; ~/openvpn-ca/pki/dh.pem /etc/openvpn/server/
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; ~/openvpn-ca/ta.key /etc/openvpn/server/

&lt;span class="c"&gt;# Verify the cert chain is valid:&lt;/span&gt;
openssl verify &lt;span class="nt"&gt;-CAfile&lt;/span&gt; /etc/openvpn/server/ca.crt /etc/openvpn/server/vpn-server.crt
&lt;span class="c"&gt;# Expected: vpn-server.crt: OK&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Phase 4: Configure OpenVPN + NAT
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 4.1: Create Server Configuration
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/openvpn/server/server.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
# ── Network ──
port 1194
proto tcp
dev tun

# ── Certificates ──
ca       /etc/openvpn/server/ca.crt
cert     /etc/openvpn/server/vpn-server.crt
key      /etc/openvpn/server/vpn-server.key
dh       /etc/openvpn/server/dh.pem
tls-auth /etc/openvpn/server/ta.key 0

# ── VPN Subnet ──
server 10.8.0.0 255.255.255.0

# ── Routing (THE important part) ──
push "route 172.31.0.0 255.255.0.0"
push "dhcp-option DNS 172.31.0.2"

# ── Security ──
cipher AES-256-GCM
auth SHA256

# ── Connection ──
keepalive 10 120
persist-key
persist-tun
ifconfig-pool-persist ipp.txt

# ── Logging ──
status      /var/log/openvpn-status.log
log-append  /var/log/openvpn.log
verb 3
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Let me explain the three directives that matter most:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;server 10.8.0.0 255.255.255.0&lt;/code&gt;&lt;/strong&gt; — Creates a virtual network for VPN clients. Server gets 10.8.0.1, first client gets 10.8.0.2, etc. This range is completely separate from your VPC (172.31.x.x) — that separation is intentional and important.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;push "route 172.31.0.0 255.255.0.0"&lt;/code&gt;&lt;/strong&gt; — This is the magic. When a client connects, OpenVPN adds a routing rule to their laptop: &lt;em&gt;"send all 172.31.x.x traffic through the VPN tunnel."&lt;/em&gt; Without this push, the client's laptop would try to reach 172.31.48.50 through its ISP, which would fail (private IP, unroutable on the internet).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;push "dhcp-option DNS 172.31.0.2"&lt;/code&gt;&lt;/strong&gt; — Uses the VPC's built-in DNS resolver (always at VPC CIDR base + 2). This ensures the RDS hostname resolves to its &lt;strong&gt;private&lt;/strong&gt; IP, not its (nonexistent) public one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4.2: Set Up NAT (The Networking Punchline)
&lt;/h3&gt;

&lt;p&gt;Here's the problem: your VPN client has IP 10.8.0.2. The RDS is at 172.31.48.50. When the VPN server forwards the client's packet to the RDS, the RDS sees:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Packet arrives at RDS:
  Source: 10.8.0.2    ← "Who? I don't know this network."
  Dest:   172.31.48.50

RDS tries to respond to 10.8.0.2 → no route exists → DROPPED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix: &lt;strong&gt;MASQUERADE&lt;/strong&gt;. The VPN server rewrites the source IP to its own VPC IP before forwarding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;After MASQUERADE:
  Source: 172.31.33.118  ← "That's a VPC IP! I know how to reach it."
  Dest:   172.31.48.50

RDS responds to 172.31.33.118 → VPN server → reverse-translates → client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Your home router does this exact same thing every day (192.168.x.x → your public IP). We're just doing it for VPN traffic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Find your network interface name&lt;/span&gt;
&lt;span class="nv"&gt;IFACE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;ip &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nt"&gt;-4&lt;/span&gt; route show to default | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $5}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Interface: &lt;/span&gt;&lt;span class="nv"&gt;$IFACE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;  &lt;span class="c"&gt;# Probably "ens5" on AWS&lt;/span&gt;

&lt;span class="c"&gt;# THE NAT RULE: rewrite source IP for VPN traffic going to VPC&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-A&lt;/span&gt; POSTROUTING &lt;span class="nt"&gt;-s&lt;/span&gt; 10.8.0.0/24 &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;$IFACE&lt;/span&gt; &lt;span class="nt"&gt;-j&lt;/span&gt; MASQUERADE

&lt;span class="c"&gt;# FORWARD RULES: allow VPN traffic to pass through the server&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-s&lt;/span&gt; 10.8.0.0/24 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT
&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-A&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-d&lt;/span&gt; 10.8.0.0/24 &lt;span class="nt"&gt;-j&lt;/span&gt; ACCEPT

&lt;span class="c"&gt;# SAVE (or they vanish on reboot)&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;service iptables save
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;iptables
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start iptables
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Breaking down the MASQUERADE rule:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-t&lt;/span&gt; nat &lt;span class="nt"&gt;-A&lt;/span&gt; POSTROUTING &lt;span class="nt"&gt;-s&lt;/span&gt; 10.8.0.0/24 &lt;span class="nt"&gt;-o&lt;/span&gt; ens5 &lt;span class="nt"&gt;-j&lt;/span&gt; MASQUERADE
                │        │              │               │         │
                │        │              │               │         └── Action: rewrite &lt;span class="nb"&gt;source &lt;/span&gt;IP
                │        │              │               └── Outgoing interface &lt;span class="o"&gt;(&lt;/span&gt;to VPC&lt;span class="o"&gt;)&lt;/span&gt;
                │        │              └── Match: &lt;span class="nb"&gt;source &lt;/span&gt;is VPN client
                │        └── Chain: after routing, before packet leaves
                └── Table: NAT &lt;span class="o"&gt;(&lt;/span&gt;address translation&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 4.3: Start OpenVPN
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;openvpn-server@server
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start openvpn-server@server

&lt;span class="c"&gt;# Verify it's running&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl status openvpn-server@server
&lt;span class="c"&gt;# Look for: "active (running)"&lt;/span&gt;

&lt;span class="c"&gt;# Verify the TUN interface exists&lt;/span&gt;
ip addr show tun0
&lt;span class="c"&gt;# Should show: inet 10.8.0.1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;What is &lt;code&gt;openvpn-server@server&lt;/code&gt;?&lt;/strong&gt; It's a systemd template. The part after &lt;code&gt;@&lt;/code&gt; is the config file name — it reads &lt;code&gt;/etc/openvpn/server/server.conf&lt;/code&gt;. If you had another config called &lt;code&gt;office.conf&lt;/code&gt;, you'd run &lt;code&gt;openvpn-server@office&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What is tun0?&lt;/strong&gt; A virtual network interface created by OpenVPN. Your server now has two interfaces:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ens5&lt;/code&gt; — real network card, connects to VPC (172.31.33.x)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tun0&lt;/code&gt; — virtual tunnel, connects to VPN network (10.8.0.1)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Encrypted VPN packets arrive on ens5:1194 → OpenVPN decrypts them → decrypted packets appear on tun0 → kernel routes them to the destination through ens5.&lt;/p&gt;




&lt;h2&gt;
  
  
  Phase 5: Connect and Test
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Step 5.1: Create a Client Config Generator
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; ~/clients

&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/generate-client.sh &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;SCRIPT&lt;/span&gt;&lt;span class="sh"&gt;'
#!/bin/bash
CLIENT=&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="sh"&gt;
ELASTIC_IP="YOUR-ELASTIC-IP-HERE"  # ← Replace with your Elastic IP

if [ -z "&lt;/span&gt;&lt;span class="nv"&gt;$CLIENT&lt;/span&gt;&lt;span class="sh"&gt;" ]; then
    echo "Usage: ./generate-client.sh &amp;lt;client-name&amp;gt;"
    exit 1
fi

cd ~/openvpn-ca

# Generate cert if it doesn't exist
if [ ! -f "pki/issued/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLIENT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;.crt" ]; then
    ./easyrsa gen-req &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLIENT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt; nopass &amp;lt;&amp;lt;&amp;lt; ""
    echo "yes" | ./easyrsa sign-req client &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLIENT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;
fi

# Build self-contained .ovpn file
cat &amp;gt; ~/clients/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLIENT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;.ovpn &amp;lt;&amp;lt; EOF
client
dev tun
proto tcp
remote &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;ELASTIC_IP&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt; 1194
resolv-retry infinite
nobind
persist-key
persist-tun
cipher AES-256-GCM
auth SHA256
key-direction 1
verb 3

&amp;lt;ca&amp;gt;
&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;pki/ca.crt&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
&amp;lt;/ca&amp;gt;
&amp;lt;cert&amp;gt;
&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;pki/issued/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLIENT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;.crt&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
&amp;lt;/cert&amp;gt;
&amp;lt;key&amp;gt;
&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;pki/private/&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLIENT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;.key&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
&amp;lt;/key&amp;gt;
&amp;lt;tls-auth&amp;gt;
&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;ta.key&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;
&amp;lt;/tls-auth&amp;gt;
EOF

echo "Created: ~/clients/&lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;CLIENT&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;.ovpn"
&lt;/span&gt;&lt;span class="no"&gt;SCRIPT

&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x ~/generate-client.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Why is everything embedded in one file?&lt;/strong&gt; The &lt;code&gt;.ovpn&lt;/code&gt; bundles the CA cert, client cert, client key, and TLS-auth key inline using XML-like tags. Instead of distributing 4 files + a config, the developer gets one file: import and connect.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5.2: Generate and Download Your Config
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On the server:&lt;/span&gt;
./generate-client.sh dev-yourname

&lt;span class="c"&gt;# On your laptop (download it):&lt;/span&gt;
scp &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s2"&gt;"your-key.pem"&lt;/span&gt; ec2-user@YOUR-ELASTIC-IP:~/clients/dev-yourname.ovpn &lt;span class="nb"&gt;.&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Security warning:&lt;/strong&gt; This file contains private keys. Do NOT send it over Slack or email. Use SCP, S3 presigned URLs (with 1-hour expiry), or physical transfer.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 5.3: Connect
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Install &lt;strong&gt;OpenVPN Connect&lt;/strong&gt; (&lt;a href="https://openvpn.net/client/" rel="noopener noreferrer"&gt;openvpn.net/client&lt;/a&gt;)&lt;/li&gt;
&lt;li&gt;Import &lt;code&gt;dev-yourname.ovpn&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Click Connect&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You should see: &lt;strong&gt;Connected&lt;/strong&gt;, assigned IP 10.8.0.x.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5.4: The Moment of Truth
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;With VPN connected:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mysql &lt;span class="nt"&gt;-h&lt;/span&gt; your-db.xxxx.region.rds.amazonaws.com &lt;span class="nt"&gt;-u&lt;/span&gt; admin &lt;span class="nt"&gt;-p&lt;/span&gt;
&lt;span class="c"&gt;# Expected: Welcome to the MySQL monitor. 🎉&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Disconnect VPN, try again:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mysql &lt;span class="nt"&gt;-h&lt;/span&gt; your-db.xxxx.region.rds.amazonaws.com &lt;span class="nt"&gt;-u&lt;/span&gt; admin &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10
&lt;span class="c"&gt;# Expected: ERROR 2003 (HY000): Can't connect (Connection timed out)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;That timeout IS the success.&lt;/strong&gt; The database is invisible to the internet. Only VPN-connected users can reach it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Complete Packet Journey
&lt;/h2&gt;

&lt;p&gt;Here's what happens end-to-end when you run &lt;code&gt;mysql -h 172.31.48.50&lt;/code&gt; through the VPN. Every single hop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. LAPTOP: "172.31.48.50? My routing table says → send through tun0 (VPN)"
     │
2. OPENVPN CLIENT: Encrypts packet + adds HMAC signature
     │
3. INTERNET: Encrypted blob travels to Elastic IP, port 1194
     │
4. VPN SERVER (ens5): Receives on port 1194
   → OpenVPN verifies HMAC (ta.key) → valid
   → Decrypts → pushes to tun0
     │
5. KERNEL: "Destination 172.31.48.50 isn't mine. IP forwarding is ON. Forward it."
     │
6. IPTABLES FORWARD: "-s 10.8.0.0/24 -j ACCEPT" → allowed
     │
7. IPTABLES NAT: MASQUERADE rewrites source 10.8.0.2 → 172.31.33.118
   (remembers the mapping for the return trip)
     │
8. VPC ROUTING: 172.31.48.50 → private subnet → arrives at RDS
     │
9. RDS SECURITY GROUP: "Is 172.31.33.118 in vpn-server-sg?" → YES → ALLOW
     │
───── RESPONSE ─────
     │
10. RDS responds to 172.31.33.118
    → iptables reverse NAT: → 10.8.0.2
    → kernel forwards to tun0
    → OpenVPN encrypts
    → sends back over internet
    → client decrypts
    → "Welcome to MySQL 8.0"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Ten hops.&lt;/strong&gt; All invisible to the developer. They just type &lt;code&gt;mysql -h ...&lt;/code&gt; and it works.&lt;/p&gt;




&lt;h2&gt;
  
  
  Operational Essentials
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Adding a New Developer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On the VPN server:&lt;/span&gt;
./generate-client.sh dev-newname
&lt;span class="c"&gt;# Transfer the .ovpn file securely&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Removing a Developer
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; ~/openvpn-ca

&lt;span class="c"&gt;# Revoke their certificate&lt;/span&gt;
./easyrsa revoke dev-departed
&lt;span class="c"&gt;# Type "yes"&lt;/span&gt;

&lt;span class="c"&gt;# Regenerate the Certificate Revocation List&lt;/span&gt;
./easyrsa gen-crl

&lt;span class="c"&gt;# Deploy it&lt;/span&gt;
&lt;span class="nb"&gt;sudo cp &lt;/span&gt;pki/crl.pem /etc/openvpn/server/

&lt;span class="c"&gt;# Add to server.conf (first time only):&lt;/span&gt;
&lt;span class="c"&gt;# crl-verify /etc/openvpn/server/crl.pem&lt;/span&gt;

&lt;span class="c"&gt;# Restart&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart openvpn-server@server
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Their &lt;code&gt;.ovpn&lt;/code&gt; file is now &lt;strong&gt;permanently useless&lt;/strong&gt;. Even if they kept a copy, the server rejects revoked certificates.&lt;/p&gt;

&lt;h3&gt;
  
  
  Seeing Who's Connected
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo cat&lt;/span&gt; /var/log/openvpn-status.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Common Pitfalls (I Hit All of Them)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. EPEL Doesn't Work on Amazon Linux 2023
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error: nothing provides redhat-release &amp;gt;= 9
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Amazon Linux 2023 isn't RHEL 9. OpenVPN is in the default repos. Install with &lt;code&gt;dnf install openvpn&lt;/code&gt; directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. iptables Chain Names Are Case-Sensitive
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-L&lt;/span&gt; forward &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;span class="c"&gt;# ERROR: chain 'forward' in table 'filter' is incompatible&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;iptables &lt;span class="nt"&gt;-L&lt;/span&gt; FORWARD &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt;
&lt;span class="c"&gt;# Works! ✅&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. EasyRSA's &lt;code&gt;make-cadir&lt;/code&gt; Doesn't Exist on Amazon Linux
&lt;/h3&gt;

&lt;p&gt;Because we installed EasyRSA manually (not from a package). The directory was created by extracting the tarball. Just &lt;code&gt;cd ~/openvpn-ca&lt;/code&gt; and run &lt;code&gt;./easyrsa&lt;/code&gt; directly.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Forgetting to Save iptables Rules
&lt;/h3&gt;

&lt;p&gt;You add the perfect MASQUERADE rule. It works. You reboot the server. It's gone. Always:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;service iptables save
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;iptables
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  5. Not Pushing DNS to Clients
&lt;/h3&gt;

&lt;p&gt;Without &lt;code&gt;push "dhcp-option DNS 172.31.0.2"&lt;/code&gt;, the RDS hostname might resolve to the wrong IP (or not resolve at all) from the client's perspective. The VPC DNS resolver knows the private IPs.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Next: Production Cutover
&lt;/h2&gt;

&lt;p&gt;Everything above creates a &lt;strong&gt;parallel test setup&lt;/strong&gt; — new RDS, new subnets, new VPN. Your existing production database is untouched.&lt;/p&gt;

&lt;p&gt;When you're ready to migrate the real database, only &lt;strong&gt;three things&lt;/strong&gt; change:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Setting&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RDS subnet group&lt;/td&gt;
&lt;td&gt;&lt;code&gt;default&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;my-private-db-subnet&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDS public access&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;No&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RDS security group&lt;/td&gt;
&lt;td&gt;The old one with 0.0.0.0/0&lt;/td&gt;
&lt;td&gt;&lt;code&gt;private-db-sg&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That's it. The VPN config, certificates, iptables rules, client &lt;code&gt;.ovpn&lt;/code&gt; files — &lt;strong&gt;none of them change&lt;/strong&gt;. They were designed to route ALL VPC traffic (172.31.0.0/16), not just one specific database. The production RDS lives within that range, so it's automatically covered.&lt;/p&gt;

&lt;p&gt;The cutover causes ~5-15 minutes of database downtime while AWS reconfigures the network placement. Take a snapshot first. Have a rollback plan.&lt;/p&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;A subnet is "private" because its route table has no Internet Gateway route — that's it&lt;/li&gt;
&lt;li&gt;OpenVPN + PKI gives you mutual certificate authentication, individual revocation, and no shared secrets&lt;/li&gt;
&lt;li&gt;NAT/MASQUERADE lets VPN clients (10.8.0.x) talk to VPC resources (172.31.x.x) by rewriting source IPs&lt;/li&gt;
&lt;li&gt;IP forwarding + iptables FORWARD rules are required for the server to route packets between networks&lt;/li&gt;
&lt;li&gt;EasyRSA is a wrapper around OpenSSL — same crypto, 10x simpler commands&lt;/li&gt;
&lt;li&gt;Amazon Linux 2023 has OpenVPN in default repos (no EPEL needed) but doesn't have EasyRSA (manual install)&lt;/li&gt;
&lt;li&gt;Design your VPN to route the entire VPC CIDR — adding new private resources requires zero VPN changes&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The database went from "accessible by the entire internet" to "accessible only through a certificate-authenticated encrypted tunnel." The infrastructure knowledge you build here — PKI, NAT, VPC networking, Linux packet routing — applies directly to Kubernetes, zero-trust architectures, and any serious infrastructure work.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This setup has been running in production since February 2026 on AWS (Amazon Linux 2023, OpenVPN 2.6.12, MySQL 8.0 on RDS, ap-south-1 region).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>security</category>
      <category>beginners</category>
      <category>networking</category>
    </item>
    <item>
      <title>[Boost]</title>
      <dc:creator>Tirth T</dc:creator>
      <pubDate>Fri, 20 Feb 2026 06:10:32 +0000</pubDate>
      <link>https://forem.com/tirtht/-32ge</link>
      <guid>https://forem.com/tirtht/-32ge</guid>
      <description>&lt;div class="ltag__link"&gt;
  &lt;a href="/tirtht" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__pic"&gt;
      &lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3778837%2F8269dac2-f4db-46b7-abed-70f659524bf1.png" alt="tirtht"&gt;
    &lt;/div&gt;
  &lt;/a&gt;
  &lt;a href="https://dev.to/tirtht/how-i-tricked-aws-elastic-beanstalk-into-using-pnpm-ef4" class="ltag__link__link"&gt;
    &lt;div class="ltag__link__content"&gt;
      &lt;h2&gt;How I Tricked AWS Elastic Beanstalk Into Using pnpm&lt;/h2&gt;
      &lt;h3&gt;Tirth T ・ Feb 18&lt;/h3&gt;
      &lt;div class="ltag__link__taglist"&gt;
        &lt;span class="ltag__link__tag"&gt;#beginners&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#devops&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#aws&lt;/span&gt;
        &lt;span class="ltag__link__tag"&gt;#npm&lt;/span&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  &lt;/a&gt;
&lt;/div&gt;


</description>
      <category>beginners</category>
      <category>devops</category>
      <category>aws</category>
      <category>npm</category>
    </item>
    <item>
      <title>How I Tricked AWS Elastic Beanstalk Into Using pnpm</title>
      <dc:creator>Tirth T</dc:creator>
      <pubDate>Wed, 18 Feb 2026 06:43:47 +0000</pubDate>
      <link>https://forem.com/tirtht/how-i-tricked-aws-elastic-beanstalk-into-using-pnpm-ef4</link>
      <guid>https://forem.com/tirtht/how-i-tricked-aws-elastic-beanstalk-into-using-pnpm-ef4</guid>
      <description>&lt;p&gt;You've migrated your Node.js project to &lt;strong&gt;pnpm&lt;/strong&gt;. Locally, everything works well — faster installs, cleaner &lt;code&gt;node_modules&lt;/code&gt;, a strict lockfile.&lt;/p&gt;

&lt;p&gt;Then you deploy to &lt;strong&gt;AWS Elastic Beanstalk&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;And it fails. &lt;/p&gt;

&lt;p&gt;Here's what happened when we pushed our first pnpm-based deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[ERROR] An error occurred during execution of command [app-deploy] - 
[InstallDependencies]. Stop running the command.
Error: install dependencies fails: Command /bin/sh -c npm --omit=dev install 
failed with error exit status 1.
Stderr: npm warn old lockfile
npm warn old lockfile The package-lock.json file was created with an old version
npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and 
package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock 
file with `npm install` before continuing.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The root cause: &lt;strong&gt;Elastic Beanstalk's Node.js platform hardcodes &lt;code&gt;npm install&lt;/code&gt;&lt;/strong&gt; during deployment. There's no config toggle, no environment variable, no checkbox in the console. It runs &lt;code&gt;npm install&lt;/code&gt;. Period.&lt;/p&gt;

&lt;p&gt;Our first fix attempt was to use &lt;code&gt;corepack enable&lt;/code&gt; — the standard way to activate pnpm on a normal Node.js installation — inside a platform hook. That also failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;.platform/hooks/prebuild/01_install_pnpm.sh: line 4: corepack: command not found
.platform/hooks/prebuild/01_install_pnpm.sh: line 22: pnpm: command not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It turns out Amazon Linux 2023's Node.js package does &lt;strong&gt;NOT&lt;/strong&gt; include &lt;code&gt;corepack&lt;/code&gt;, unlike standard nodejs.org distributions. So not only does Beanstalk force &lt;code&gt;npm install&lt;/code&gt;, you can't even install pnpm the standard way.&lt;/p&gt;

&lt;p&gt;You can't change it. But you &lt;em&gt;can&lt;/em&gt; trick it.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Strategy: An npm Wrapper
&lt;/h2&gt;

&lt;p&gt;Instead of fighting Beanstalk's lifecycle, we work &lt;em&gt;with&lt;/em&gt; it. The approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Install pnpm&lt;/strong&gt; on the instance (using Corepack or npm, depending on the platform)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replace the &lt;code&gt;npm&lt;/code&gt; binary&lt;/strong&gt; with a wrapper script that intercepts &lt;code&gt;install&lt;/code&gt; and &lt;code&gt;ci&lt;/code&gt; commands and silently redirects them to &lt;code&gt;pnpm&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;All other &lt;code&gt;npm&lt;/code&gt; commands (&lt;code&gt;--version&lt;/code&gt;, &lt;code&gt;run&lt;/code&gt;, etc.) still use the original npm&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Beanstalk thinks it's running npm. It's actually running pnpm. It never knows the difference.&lt;/p&gt;

&lt;p&gt;Here's the architecture:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Beanstalk Lifecycle
    │
    ├─ prebuild hook ──► Install pnpm via Corepack
    │                    Replace /usr/bin/npm with wrapper
    │
    ├─ npm install ────► Wrapper intercepts → pnpm install
    │
    └─ npm start ──────► Wrapper passes through → original npm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Project Structure
&lt;/h2&gt;

&lt;p&gt;Elastic Beanstalk uses a &lt;code&gt;.platform&lt;/code&gt; directory for lifecycle hooks. Here's what we need:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;your-project/
├── .platform/
│   └── hooks/
│       └── prebuild/
│           └── 01_install_pnpm.sh    ← The core script
├── Procfile                           ← web: pnpm start
├── package.json
├── pnpm-lock.yaml                     ← Your pnpm lockfile
└── ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Why &lt;code&gt;hooks/prebuild&lt;/code&gt;?&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Beanstalk runs hooks in this order: &lt;code&gt;prebuild&lt;/code&gt; → &lt;code&gt;npm install&lt;/code&gt; → &lt;code&gt;predeploy&lt;/code&gt; → &lt;code&gt;postdeploy&lt;/code&gt;. We need pnpm ready &lt;strong&gt;before&lt;/strong&gt; Beanstalk attempts to install dependencies.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Bug That Almost Broke Everything
&lt;/h2&gt;

&lt;p&gt;Our first version of the script had a critical &lt;strong&gt;chicken-and-egg bug&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;1. Script runs: npm install -g pnpm
2. Script replaces /usr/bin/npm with wrapper that calls pnpm
3. Deployment FAILS (some other reason)
4. Next deployment tries again...
5. npm is now broken! It tries to call pnpm, but pnpm doesn't exist!
6. "pnpm: command not found" — forever
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wrapper was created &lt;strong&gt;before&lt;/strong&gt; verifying pnpm was properly installed. If anything failed between installation and wrapper creation, npm would be permanently broken on the instance — it would try to call pnpm (which doesn't exist), and since npm is broken, you can't even &lt;code&gt;npm install -g pnpm&lt;/code&gt; to fix it.&lt;/p&gt;

&lt;p&gt;This is why the script must:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Always restore the original npm first&lt;/strong&gt; (safety net)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify pnpm is installed&lt;/strong&gt; before creating the wrapper&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only then&lt;/strong&gt; replace npm with the wrapper&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  The Platform Hook Script
&lt;/h2&gt;

&lt;p&gt;Create &lt;code&gt;.platform/hooks/prebuild/01_install_pnpm.sh&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/usr/bin/env bash&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt;

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== Starting pnpm installation via Corepack ==="&lt;/span&gt;

&lt;span class="c"&gt;# ─── SAFETY NET ─────────────────────────────────────&lt;/span&gt;
&lt;span class="c"&gt;# If a previous deployment failed mid-script, the original&lt;/span&gt;
&lt;span class="c"&gt;# npm binary might be stuck at /usr/bin/npm_original.&lt;/span&gt;
&lt;span class="c"&gt;# Restore it before we do anything else.&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /usr/bin/npm_original &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Restoring original npm from backup..."&lt;/span&gt;
    &lt;span class="nb"&gt;mv&lt;/span&gt; /usr/bin/npm_original /usr/bin/npm
&lt;span class="k"&gt;fi&lt;/span&gt;

&lt;span class="c"&gt;# ─── INSTALL COREPACK ───────────────────────────────&lt;/span&gt;
&lt;span class="c"&gt;# Amazon Linux 2023 does NOT include corepack unlike&lt;/span&gt;
&lt;span class="c"&gt;# standard nodejs.org Node.js distributions.&lt;/span&gt;
&lt;span class="c"&gt;# We must bootstrap it using npm first.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Installing corepack..."&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; corepack

&lt;span class="c"&gt;# ─── ENABLE PNPM VIA COREPACK ──────────────────────&lt;/span&gt;
&lt;span class="c"&gt;# 'corepack enable' creates a shim (lightweight proxy) at /usr/bin/pnpm&lt;/span&gt;
&lt;span class="c"&gt;# It doesn't download pnpm yet — just creates the pointer.&lt;/span&gt;
&lt;span class="c"&gt;# 'corepack prepare' actually downloads the binary and caches it,&lt;/span&gt;
&lt;span class="c"&gt;# preventing the "Do you want to download?" prompt during automated builds.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Enabling corepack and installing pnpm..."&lt;/span&gt;
corepack &lt;span class="nb"&gt;enable
&lt;/span&gt;corepack prepare pnpm@latest &lt;span class="nt"&gt;--activate&lt;/span&gt;

&lt;span class="c"&gt;# ─── VERIFY INSTALLATION ───────────────────────────&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;command&lt;/span&gt; &lt;span class="nt"&gt;-v&lt;/span&gt; pnpm &amp;amp;&amp;gt; /dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"ERROR: pnpm installation failed!"&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi
&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"pnpm installed successfully: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;pnpm &lt;span class="nt"&gt;--version&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

&lt;span class="c"&gt;# ─── THE NPM WRAPPER ───────────────────────────────&lt;/span&gt;
&lt;span class="c"&gt;# Only AFTER pnpm is verified, it's safe to create the wrapper.&lt;/span&gt;
&lt;span class="c"&gt;# We backup the real npm binary, then replace it with a script&lt;/span&gt;
&lt;span class="c"&gt;# that intercepts install/ci commands and redirects to pnpm.&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Creating npm wrapper..."&lt;/span&gt;
&lt;span class="nb"&gt;mv&lt;/span&gt; /usr/bin/npm /usr/bin/npm_original

&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /usr/bin/npm &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;'
#!/bin/bash

# Loop through ALL arguments — not just &lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="sh"&gt;.
# Beanstalk runs: npm --omit=dev install
# If you only check &lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="sh"&gt;, you'd see --omit=dev and miss 'install' entirely.
for arg in "&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="sh"&gt;"; do
    case "&lt;/span&gt;&lt;span class="nv"&gt;$arg&lt;/span&gt;&lt;span class="sh"&gt;" in
        install|ci|add)
            echo "[npm-wrapper] Intercepted install → using pnpm"
            pnpm install --prod --frozen-lockfile --ignore-scripts
            exit &lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="sh"&gt;
            ;;
        rebuild|update|prune|dedupe)
            echo "[npm-wrapper] Intercepted &lt;/span&gt;&lt;span class="nv"&gt;$arg&lt;/span&gt;&lt;span class="sh"&gt; → skipping (pnpm handles this)"
            exit 0
            ;;
    esac
done

# Everything else (--version, run, start, etc) → use original npm
/usr/bin/npm_original "&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="sh"&gt;"
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;chmod&lt;/span&gt; +x /usr/bin/npm

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"=== pnpm installation complete ==="&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; The file MUST have Linux line endings (LF, not CRLF). If you're on Windows, configure your editor or use &lt;code&gt;git config core.autocrlf input&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Make It Executable
&lt;/h2&gt;

&lt;p&gt;This is the step everyone forgets. The hook script must be executable, or Beanstalk silently ignores it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# If you're on macOS/Linux&lt;/span&gt;
&lt;span class="nb"&gt;chmod&lt;/span&gt; +x .platform/hooks/prebuild/01_install_pnpm.sh

&lt;span class="c"&gt;# If you're on Windows, use Git to track the permission&lt;/span&gt;
git update-index &lt;span class="nt"&gt;--chmod&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;+x .platform/hooks/prebuild/01_install_pnpm.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Understanding the Wrapper: The &lt;code&gt;$1&lt;/code&gt; Bug
&lt;/h2&gt;

&lt;p&gt;Our original wrapper only checked &lt;code&gt;$1&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# BROKEN — only checks the first argument&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"install"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$1&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ci"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;pnpm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--prod&lt;/span&gt; &lt;span class="nt"&gt;--frozen-lockfile&lt;/span&gt; &lt;span class="nt"&gt;--ignore-scripts&lt;/span&gt;
&lt;span class="k"&gt;else&lt;/span&gt;
    /usr/bin/npm_original &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This broke because Beanstalk runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nt"&gt;--omit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;dev &lt;span class="nb"&gt;install&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;$1&lt;/code&gt; is &lt;code&gt;--omit=dev&lt;/code&gt;, not &lt;code&gt;install&lt;/code&gt;. The wrapper missed it entirely and fell through to the original npm, which failed because there's no &lt;code&gt;package-lock.json&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix was to loop through ALL arguments using &lt;code&gt;for arg in "$@"&lt;/code&gt; and use a &lt;code&gt;case&lt;/code&gt; statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;arg &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$@&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
    case&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$arg&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="k"&gt;in
        &lt;/span&gt;&lt;span class="nb"&gt;install&lt;/span&gt;&lt;span class="p"&gt;|&lt;/span&gt;ci|add&lt;span class="p"&gt;)&lt;/span&gt;
            pnpm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--prod&lt;/span&gt; &lt;span class="nt"&gt;--frozen-lockfile&lt;/span&gt; &lt;span class="nt"&gt;--ignore-scripts&lt;/span&gt;
            &lt;span class="nb"&gt;exit&lt;/span&gt; &lt;span class="nv"&gt;$?&lt;/span&gt;
            &lt;span class="p"&gt;;;&lt;/span&gt;
        rebuild|update|prune|dedupe&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="nb"&gt;exit &lt;/span&gt;0  &lt;span class="c"&gt;# Skip — pnpm handles it&lt;/span&gt;
            &lt;span class="p"&gt;;;&lt;/span&gt;
    &lt;span class="k"&gt;esac&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why intercept &lt;code&gt;rebuild&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt;, &lt;code&gt;prune&lt;/code&gt;, and &lt;code&gt;dedupe&lt;/code&gt;?
&lt;/h3&gt;

&lt;p&gt;Beanstalk runs &lt;code&gt;npm rebuild&lt;/code&gt; and sometimes &lt;code&gt;npm prune&lt;/code&gt; after install. With pnpm, these are unnecessary — pnpm handles everything during install. We skip them with &lt;code&gt;exit 0&lt;/code&gt; so Beanstalk doesn't interpret it as a failure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why &lt;code&gt;--prod --frozen-lockfile --ignore-scripts&lt;/code&gt;?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Flag&lt;/th&gt;
&lt;th&gt;Why&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--prod&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Only install production dependencies (skip devDependencies)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--frozen-lockfile&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fail if lockfile is out of sync — prevents "works on my machine" bugs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;--ignore-scripts&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Skip postinstall scripts during deploy for security and speed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Understanding Corepack Shims
&lt;/h2&gt;

&lt;p&gt;When you run &lt;code&gt;corepack enable&lt;/code&gt;, it doesn't install the &lt;strong&gt;real&lt;/strong&gt; pnpm binary. It creates a &lt;strong&gt;shim&lt;/strong&gt; — a lightweight proxy script at &lt;code&gt;/usr/bin/pnpm&lt;/code&gt; that intercepts your command.&lt;/p&gt;

&lt;p&gt;Running &lt;code&gt;corepack prepare pnpm@latest --activate&lt;/code&gt; actually &lt;strong&gt;pre-downloads&lt;/strong&gt; the real binary into the system cache. Without this step, the shim would prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-10.28.2.tgz
? Do you want to continue? [Y/n]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prompt would hang an automated deployment. The &lt;code&gt;--activate&lt;/code&gt; flag ensures it downloads silently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Note:&lt;/strong&gt; If you SSH into the instance as a different user (e.g., &lt;code&gt;ec2-user&lt;/code&gt;), you may still see this prompt because the user-specific cache is empty. The web application runs fine because the deployment user (root/webapp) already had pnpm "prepared" by the script.&lt;/p&gt;




&lt;h2&gt;
  
  
  Deploy and Verify
&lt;/h2&gt;

&lt;p&gt;Push your code and watch the Beanstalk logs. Here's what a successful deployment looks like (from our actual &lt;code&gt;eb-hooks.log&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;=== Starting pnpm installation via Corepack ===
Restoring original npm from backup...
Installing corepack...
changed 1 package in 1s
Enabling corepack and installing pnpm...
Preparing pnpm@latest for immediate activation...
pnpm installed successfully: 10.28.2
Creating npm wrapper...
=== pnpm installation complete ===
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Performance Results (February 2, 2026)
&lt;/h2&gt;

&lt;p&gt;These are actual numbers from our production Elastic Beanstalk environment after fixing the npm wrapper bug:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Before (npm)&lt;/th&gt;
&lt;th&gt;After (pnpm)&lt;/th&gt;
&lt;th&gt;Improvement&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Total Deploy Stage&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~6 minutes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1 min 6 sec&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.5x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Package Install&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~3 minutes&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13 seconds&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;14x faster&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;npm rebuild&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~5 seconds&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Skipped&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;N/A&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Troubleshooting
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Key Log Files
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Log File&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/var/log/eb-hooks.log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Prebuild script output (your script logs here)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/var/log/eb-engine.log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Deployment steps, artifact downloads&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;/var/log/web.stdout.log&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Application runtime output/errors&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# View last 100 lines of each log&lt;/span&gt;
&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-100&lt;/span&gt; /var/log/eb-hooks.log
&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-100&lt;/span&gt; /var/log/eb-engine.log
&lt;span class="nb"&gt;sudo tail&lt;/span&gt; &lt;span class="nt"&gt;-100&lt;/span&gt; /var/log/web.stdout.log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Diagnosing the npm Wrapper State
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Check if npm is original or wrapper&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /usr/bin/npm

&lt;span class="c"&gt;# If it shows the wrapper script, npm has been replaced&lt;/span&gt;
&lt;span class="c"&gt;# If it shows the original npm content (long script), it's untouched&lt;/span&gt;

&lt;span class="c"&gt;# Check if backup exists&lt;/span&gt;
&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /usr/bin/npm_original

&lt;span class="c"&gt;# Check if pnpm exists&lt;/span&gt;
which pnpm
pnpm &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Fixing Broken State Manually
&lt;/h3&gt;

&lt;p&gt;If npm is broken on the instance (wrapper exists but pnpm doesn't):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Restore the original npm&lt;/span&gt;
&lt;span class="nb"&gt;sudo mv&lt;/span&gt; /usr/bin/npm_original /usr/bin/npm

&lt;span class="c"&gt;# Then install pnpm manually&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;npm &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; pnpm

&lt;span class="c"&gt;# Verify&lt;/span&gt;
pnpm &lt;span class="nt"&gt;--version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Running the App Manually via SSH
&lt;/h3&gt;

&lt;p&gt;SSH sessions don't have Elastic Beanstalk environment variables. Load them first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;su -
&lt;span class="nb"&gt;source&lt;/span&gt; /opt/elasticbeanstalk/deployment/env
&lt;span class="nb"&gt;cd&lt;/span&gt; /var/app/current
pnpm start
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Common Errors &amp;amp; Solutions
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Error&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Solution&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;corepack: command not found&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Amazon Linux doesn't include corepack&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;npm install -g corepack&lt;/code&gt; first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;pnpm: command not found&lt;/code&gt; (inside npm)&lt;/td&gt;
&lt;td&gt;npm wrapper created before pnpm installed&lt;/td&gt;
&lt;td&gt;Restore &lt;code&gt;npm_original&lt;/code&gt;, then reinstall&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;App crashes with missing env var&lt;/td&gt;
&lt;td&gt;SSH session doesn't have EB env vars&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;source /opt/elasticbeanstalk/deployment/env&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rollback loop&lt;/td&gt;
&lt;td&gt;Old broken version keeps deploying&lt;/td&gt;
&lt;td&gt;Disable auto-rollback in CodePipeline (set to "None")&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;AWS Elastic Beanstalk hardcodes &lt;code&gt;npm install&lt;/code&gt; — you can't change it&lt;/li&gt;
&lt;li&gt;Amazon Linux 2023 doesn't include &lt;code&gt;corepack&lt;/code&gt; — you must bootstrap it with &lt;code&gt;npm install -g corepack&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Create a &lt;code&gt;.platform/hooks/prebuild/&lt;/code&gt; hook that installs pnpm and replaces npm with a wrapper&lt;/li&gt;
&lt;li&gt;The wrapper must loop through ALL arguments (not just &lt;code&gt;$1&lt;/code&gt;) because Beanstalk runs &lt;code&gt;npm --omit=dev install&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Always restore the original npm first to avoid the chicken-and-egg bug&lt;/li&gt;
&lt;li&gt;Result: package install dropped from ~3 minutes to 13 seconds&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Should AWS Just Support pnpm Natively?
&lt;/h2&gt;

&lt;p&gt;Yes. But until that day comes, this wrapper approach is production-tested and running across multiple services without issues.&lt;/p&gt;

&lt;p&gt;If you found this useful, feel free to share or leave a comment.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This approach has been running in production since December 2025 across multiple Node.js services on Elastic Beanstalk (Amazon Linux 2023, Node.js 22.x platform).&lt;/em&gt;&lt;/p&gt;

</description>
      <category>beginners</category>
      <category>devops</category>
      <category>aws</category>
      <category>npm</category>
    </item>
  </channel>
</rss>
