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.
चला मित्रांनो! Hotel सारखा विचार करा: reception (Nginx) समोर, kitchen (PHP) आत, आणि store room (MySQL) अजून आत. Guest फक्त reception ला भेटतो, reception order kitchen ला देतो, kitchen store room मधून सामान घेतो. कोणीही guest थेट kitchen किंवा store room मध्ये जात नाही. आज हेच AWS वर बनवूया – students list दाखवणारी छोटी website. तर सगळ्यात पहिलं काम – network.
चलो दोस्तों! Hotel जैसा सोचो: reception (Nginx) सामने, kitchen (PHP) अंदर, और store room (MySQL) और अंदर. Guest सिर्फ reception से मिलता है, reception order kitchen को देता है, kitchen store room से सामान लेता है. कोई भी guest सीधे kitchen या store room में नहीं जाता. आज यही AWS पर बनाएँगे – students list दिखाने वाली छोटी website. तो सबसे पहला काम – 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.microinstances, 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?
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.
Browser public subnet मधल्या Nginx पर्यंत port 80 वर पोहोचतो. Nginx PHP request fastcgi_pass ने port 9000 वरच्या PHP-FPM कडे पाठवतो, PHP port 3306 वरच्या MySQL मधून students वाचतो, आणि HTML page परत जातं. प्रत्येक security group फक्त त्याच्या आधीच्या tier ला allow करतो.
Browser public subnet में Nginx तक port 80 पर पहुँचता है. Nginx PHP request को fastcgi_pass से port 9000 वाले PHP-FPM को भेजता है, PHP port 3306 वाले MySQL से students पढ़ता है, और HTML page वापस जाता है. हर security group सिर्फ अपने पहले वाले tier को allow करता है.
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?
Ravindra Bagale's Tip – मराठी
हा project interview मध्ये सांगा – "मी 3-tier VPC बनवला, security group chaining केली". पण आधी स्वतः तोड-फोड करा: listen.allowed_clients चुकवून 502 बघा, app-sg मधला 9000 rule काढून timeout बघा. Error स्वतः पाहिलेत तर interview मध्ये confidently सांगाल. समजलं का?
Ravindra Bagale's Tip – हिंदी
यह project interview में बताओ – "मैंने 3-tier VPC बनाया, security group chaining की". पर पहले खुद तोड़-फोड़ करो: listen.allowed_clients गलत करके 502 देखो, app-sg से 9000 वाला rule हटाकर timeout देखो. Error खुद देखे होंगे तो interview में confidently बता पाओगे. समझ आया?
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.
Learn it properly
The free AWS course builds this project slowly, with every concept explained:
- Why do we need subnets? The 3-tier layout
- Project: build the 3-tier network and launch the servers
- Project: MySQL, PHP-FPM and Nginx on three tiers
- Project: test every hop, troubleshoot and clean up
- Related guides: Create a custom VPC · NAT gateway · SSH through a bastion · Security group vs NACL · Create RDS MySQL and connect from EC2
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!
समजलं का? Nginx पब्लिक मध्ये, PHP-FPM आणि MySQL प्रायव्हेट मध्ये, fastcgi_pass 9000 वर, MySQL 3306 वर, आणि सिक्युरिटी ग्रुप ची चेन. PHP फाइल्स PHP सर्व्हर वर, स्टॅटिक फाइल्स Nginx वर. शाब्बास – तुमचा पहिला 3-tier प्रोजेक्ट!
समझ आया? Nginx पब्लिक में, PHP-FPM और MySQL प्राइवेट में, fastcgi_pass 9000 पर, MySQL 3306 पर, और सिक्योरिटी ग्रुप की चेन. PHP फाइल्स PHP सर्वर पर, स्टैटिक फाइल्स Nginx पर. शाबाश – आपका पहला 3-tier प्रोजेक्ट!
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.