Ravindra BagaleCourses & study guides Track your progress

Guides

3-Tier Architecture on AWS: Nginx, PHP-FPM and MySQL in a Custom VPC (Full Project)

A 3-tier architecture on AWS puts Nginx (web tier) in a public subnet, PHP-FPM (app tier) in a private subnet, and MySQL (database tier) in another private subnet of a custom VPC. Nginx forwards .php requests with fastcgi_pass 10.0.2.10:9000, PHP-FPM listens on port 9000 and connects to MySQL on port 3306, and security groups are chained: web-sg → app-sg → db-sg. A bastion host gives SSH access and a NAT gateway gives the private servers outbound internet for installs.

Come on, friends! Think of a hotel: the reception (Nginx) in front, the kitchen (PHP) inside, and the store room (MySQL) further inside. The guest meets only the reception, the reception gives the order to the kitchen, and the kitchen takes supplies from the store room. No guest goes straight into the kitchen or the store room. Today we build exactly this on AWS – a small website that shows a students list. So the very first job – the network.

Quick answer

The three key configurations (private IPs: web 10.0.1.10, app 10.0.2.10, db 10.0.3.10):

# web server: /etc/nginx/default.d/php-app.conf
index index.php index.html;
location ~ \.php$ {
    fastcgi_pass 10.0.2.10:9000;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME /var/www/html$fastcgi_script_name;
}
; app server: /etc/php-fpm.d/www.conf
listen = 0.0.0.0:9000
listen.allowed_clients = 10.0.1.10
# db server: /etc/my.cnf.d/mariadb-server.cnf, under [mysqld]
bind-address = 0.0.0.0
web-sg: HTTP 80 / HTTPS 443 from 0.0.0.0/0, SSH 22 from bastion-sg
app-sg: TCP 9000 from web-sg,               SSH 22 from bastion-sg
db-sg:  MySQL 3306 from app-sg,             SSH 22 from bastion-sg

What do I need before building the 3-tier project?

  • An AWS account, a key pair (society-key) and about two hours. Region in the examples: ap-south-1.
  • Basic SSH through a bastion: How to SSH into a private EC2 through a bastion.
  • A cost check: four t3.micro/t2.micro instances, a NAT gateway (billed per hour and per GB) and its Elastic IP. Delete everything at the end (Step 8).

How does a request travel through the three tiers?

Request flow in the 3-tier project: Nginx, PHP-FPM and MySQL The browser sends a request through the internet gateway to Nginx in the public web subnet on port 80. Nginx forwards the PHP request with fastcgi_pass to PHP-FPM on the app server 10.0.2.10 port 9000 in a private subnet. PHP connects to MySQL (MariaDB) on 10.0.3.10 port 3306 in the private DB subnet, gets the students, and the finished HTML page travels back to the browser. Each security group allows traffic only from the tier before it. Browser society-vpc 10.0.0.0/16 IGW public 10.0.1.0/24 Nginxweb-sg :80 private 10.0.2.0/24 PHP-FPMapp-sg :9000 private 10.0.3.0/24 MySQLdb-sg :3306 from 0.0.0.0/0 only from web-sg only from app-sg Studentslist ✓ GET / HTML Nginx → fastcgi_pass :9000 → PHP-FPM → PDO :3306 → MySQL

The browser reaches Nginx in the public subnet on port 80. Nginx passes the PHP request with fastcgi_pass to PHP-FPM on port 9000, PHP reads the students from MySQL on port 3306, and the HTML page goes back. Each security group allows only the tier before it.

How do I build a 3-tier Nginx, PHP and MySQL setup step by step?

Step 1 — Build the network

Create a custom VPC society-vpc 10.0.0.0/16 with:

Subnet CIDR Route table
web-public-1a 10.0.1.0/24 public-rt: 0.​0.​0.​0/​0 → igw-…
app-private-1a 10.0.2.0/24 private-rt: 0.​0.​0.​0/​0 → nat-…
db-private-1a 10.0.3.0/24 private-rt

Create the NAT gateway in web-public-1a with an Elastic IP. For production you would repeat the subnets in a second AZ (6 subnets), and an RDS DB subnet group needs subnets in at least 2 AZs.

Step 2 — Create the chained security groups

Create them in this order, because each one refers to the previous one:

Security group Inbound rules
bastion-sg SSH 22 from My IP
web-sg HTTP 80 and HTTPS 443 from 0.0.0.0/0; SSH 22 from bastion-sg
app-sg Custom TCP 9000 from web-sg; SSH 22 from bastion-sg
db-sg MySQL/Aurora 3306 from app-sg; SSH 22 from bastion-sg

Step 3 — Launch four Amazon Linux 2023 instances

Name Subnet Security group Public IP
bastion web-public-1a bastion-sg Yes
web web-public-1a web-sg Yes
app app-private-1a app-sg No
db db-private-1a db-sg No

Note each private IP. This guide assumes web 10.0.1.10, app 10.0.2.10, db 10.0.3.10; replace them with yours. Load your key with ssh-add society-key.pem and reach private servers with ssh -J ec2-user@<BASTION_PUBLIC_IP> ec2-user@<PRIVATE_IP>.

Step 4 — Database tier: MariaDB (MySQL-compatible)

On db:

sudo dnf install mariadb105-server -y
sudo service mariadb start
sudo systemctl enable mariadb       # systemctl: the modern equivalent, enable = start at boot
sudo mysql_secure_installation
sudo nano /etc/my.cnf.d/mariadb-server.cnf     # under [mysqld] add: bind-address = 0.0.0.0
sudo service mariadb restart
sudo ss -tlnp | grep 3306

0.0.0.0 listens on all interfaces; db-sg still allows only app-sg. You can use 10.0.3.10 instead. Then sudo mysql and run:

CREATE DATABASE appdb;
CREATE USER 'app'@'10.0.2.%' IDENTIFIED BY 'Society@Pass123';
GRANT SELECT, INSERT ON appdb.* TO 'app'@'10.0.2.%';
USE appdb;
CREATE TABLE students (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, city VARCHAR(50) NOT NULL);
INSERT INTO students (name, city) VALUES ('Aarav Patil','Pune'), ('Sneha Deshmukh','Nagpur'), ('Rohan Kulkarni','Nashik');
EXIT;

'app'@'10.0.2.%' allows the user only from the app subnet. Use your own password.

Step 5 — App tier: PHP-FPM on port 9000

On app:

sudo yum install php-fpm php-mysqlnd -y
sudo sed -i 's|^listen = .*|listen = 0.0.0.0:9000|' /etc/php-fpm.d/www.conf
sudo sed -i 's|^;*listen.allowed_clients = .*|listen.allowed_clients = 10.0.1.10|' /etc/php-fpm.d/www.conf
grep -E '^listen' /etc/php-fpm.d/www.conf
sudo service php-fpm start
sudo systemctl enable php-fpm
sudo ss -tlnp | grep 9000

By default PHP-FPM listens on a Unix socket, which only local programs can use. Nginx is on another server, so it must listen on TCP 9000, and listen.allowed_clients must be the Nginx server's private IP.

With FastCGI, the PHP files must exist on the PHP server, because Nginx only sends the file path. Create the folder with sudo mkdir -p /var/www/html, then /var/www/html/db.php (for example with sudo nano):

<?php
$pdo = new PDO("mysql:host=10.0.3.10;dbname=appdb;charset=utf8mb4", "app", "Society@Pass123",
               [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);

and /var/www/html/index.php:

<?php
require __DIR__ . "/db.php";
$rows = $pdo->query("SELECT id, name, city FROM students ORDER BY id")->fetchAll(PDO::FETCH_ASSOC);
?>
<!DOCTYPE html>
<html><head><meta charset="utf-8"><title>Students</title><link rel="stylesheet" href="/style.css"></head>
<body><h1>Students list</h1>
<table><tr><th>ID</th><th>Name</th><th>City</th></tr>
<?php foreach ($rows as $r): ?>
<tr><td><?= $r["id"] ?></td><td><?= htmlspecialchars($r["name"]) ?></td><td><?= htmlspecialchars($r["city"]) ?></td></tr>
<?php endforeach; ?>
</table><p>Served by PHP on <?= htmlspecialchars(gethostname()) ?></p></body></html>
sudo chown root:apache /var/www/html/db.php
sudo chmod 640 /var/www/html/db.php    # the password file: readable by root and PHP-FPM (group apache) only

Step 6 — Web tier: Nginx with fastcgi_pass

On web (install Nginx only; do not install php-fpm here):

sudo yum install nginx -y
sudo service nginx start
sudo systemctl enable nginx
sudo nano /etc/nginx/default.d/php-app.conf     # paste the Nginx block from the Quick answer
sudo touch /usr/share/nginx/html/index.php      # empty placeholder so "index index.php" matches
echo 'body{font-family:Arial;max-width:700px;margin:40px auto}td,th{border:1px solid #999;padding:6px}' | sudo tee /usr/share/nginx/html/style.css
sudo nginx -t
sudo service nginx reload
Line Meaning
location ~ \.​php$ Requests ending in .php go to this block
fastcgi_​pass 10.​0.​2.​10:​9000; Send them to PHP-FPM on the app server
include fastcgi_​params; Pass the standard request variables
fastcgi_​param SCRIPT_​FILENAME /​var/​www/​html$fastcgi_​script_​name; The file path on the app server

The empty index.php on the web server lets the index directive pick /index.php for /; the real file runs on the app server. Static files (style.css, images) stay on the Nginx server. Always sudo nginx -t before reloading.

Simpler alternative: run Apache with PHP on the app server (sudo yum install httpd php php-mysqlnd -y), allow TCP 80 from web-sg in app-sg, and in Nginx use location / { proxy_pass http://10.0.2.10; proxy_set_header Host $host; }. Then all files live on the app server.

Step 7 — Test every hop

Open http://<WEB_PUBLIC_IP>/: you should see the Students list with three rows. 🎉

Hop Run on Command Good result
Web → app web timeout 3 bash -​c 'echo > /​dev/​tcp/​10.​0.​2.​10/​9000' && echo OPEN OPEN
App → db app timeout 3 bash -​c 'echo > /​dev/​tcp/​10.​0.​3.​10/​3306' && echo OPEN OPEN
Private → internet app curl -​s https://​checkip.​amazonaws.​com NAT gateway Elastic IP
Web → db (must fail) web timeout 3 bash -​c 'echo > /​dev/​tcp/​10.​0.​3.​10/​3306' times out

Step 8 — Clean up

Terminate the four instances → delete the NAT gateway (wait for Deleted) → release the Elastic IP → Delete VPC (removes subnets, route tables, IGW, NACLs, security groups).

Ravindra Bagale's Tip

Talk about this project in interviews – "I built a 3-tier VPC and did security group chaining". But first break things yourself: get listen.allowed_clients wrong and see the 502, remove the 9000 rule from app-sg and see the timeout. If you have seen the errors yourself, you will explain them confidently in the interview. Got it?

How do I fix 3-tier errors?

Ghabru naka 😅 — these are the usual ones:

Symptom Likely cause Fix
502 Bad Gateway, log (111: Connection refused) PHP-FPM stopped or still on the socket Check grep ^listen /​etc/​php-​fpm.​d/​www.​conf; sudo service php-​fpm restart
502/504, log (110: Connection timed out) app-sg lacks TCP 9000 from web-sg Add the rule
502, log (104: Connection reset by peer) listen.​allowed_​clients is not the web server's IP Set 10.0.1.10, restart php-fpm
File not found. / Primary script unknown PHP file missing on the app server or wrong SCRIPT_FILENAME Put index.php in /var/www/html on the app server
Nginx welcome page Placeholder index.php missing on web, or config not in default.d Create it; sudo nginx -t; reload
SQLSTATE[HY000] [2002] Connection timed out db-sg lacks 3306 from app-sg Add the rule
SQLSTATE[HY000] [2002] Connection refused MariaDB bound to localhost Set bind-address, restart mariadb
SQLSTATE[HY000] [1045] Access denied Wrong password or user host Recreate 'app'@'10.0.2.%'
yum hangs on app/db No NAT route Add 0.​0.​0.​0/​0 → nat-… to private-rt

Try it at home

Rebuild the project from scratch, then add an add.php page with a form that inserts a new student (the app user already has INSERT). Show a friend the students list and explain why the database has no public IP. Clean up the same day.

Got it? Nginx in public, PHP-FPM and MySQL in private, fastcgi_pass on 9000, MySQL on 3306, and the security group chain. PHP files on the PHP server, static files on Nginx. Well done – your first 3-tier project!

Frequently asked questions

What is a 3-tier architecture in AWS?

A design with a web tier in public subnets, an application tier in private subnets and a database tier in private subnets, where each tier accepts traffic only from the tier in front of it.

How does Nginx send PHP requests to another server?

With fastcgi_pass APP_PRIVATE_IP:9000; in a location ~ \.php$ block. PHP-FPM on the app server must listen on TCP 9000 and allow the Nginx IP in listen.allowed_clients.

Where do the PHP files go when Nginx and PHP-FPM are on different servers?

On the PHP-FPM server, at the path given in SCRIPT_FILENAME (here /var/www/html). Static files such as CSS and images stay on the Nginx server.

Why do I get 502 Bad Gateway from Nginx?

Nginx cannot talk to PHP-FPM: PHP-FPM is stopped or on a socket (Connection refused), the security group blocks 9000 (timed out), or listen.allowed_clients does not include the Nginx IP (connection reset).

Which MySQL package do I install on Amazon Linux 2023?

sudo dnf install mariadb105-server installs MariaDB 10.5, a MySQL-compatible server. For a managed database, use Amazon RDS for MySQL in private subnets instead.

What is security group chaining?

Using a security group as the source of another group's rule: app-sg allows 9000 from web-sg, db-sg allows 3306 from app-sg. It keeps working when IPs change and blocks everything else.