Overview
| Component | Choice |
|---|---|
| OS | Amazon Linux 2023 (AL2023) |
| Web server | nginx |
| Runtime | PHP-FPM (PHP 8.3 recommended) |
| Database | MariaDB (local) — RDS noted as an alternative |
| TLS | Let’s Encrypt via Certbot |
1. EC2 instance and networking
- Launch the AL2023 instance.
- Security group inbound: 22 (SSH, ideally locked to your IP), 80, 443.
Point DNS before the TLS step (Section 11) — Certbot validates over HTTP and needs the domain already resolving to the box.
2. nginx
sudo dnf update -y
sudo dnf install -y nginx
sudo systemctl enable --now nginx
A hit on http://<your-ip>/ should now return the nginx welcome page.
3. PHP-FPM and extensions
On AL2023, the cURL extension is supplied by php8.4-common; there is no php8.4-curl package.
sudo dnf install -y \
php8.4-fpm \
php8.4-cli \
php8.4-common \
php8.4-mysqlnd \
php8.4-gd \
php8.4-mbstring \
php8.4-xml \
php8.4-zip \
php8.4-intl \
php8.4-opcache \
php8.4-bcmath
Verify the installation and required extensions:
php -v
php -m | grep -Ei \
'bcmath|curl|gd|intl|mbstring|mysqli|mysqlnd|opcache|xml|zip'
Configure the pool — align the user with nginx (critical)
AL2023’s php-fpm inherits RHEL defaults and runs as apache, but nginx and your files will be nginx. That mismatch causes 502s, permission errors, and the WordPress “enter your FTP credentials” prompt. Fix it up front:
grep -E '^(user|group)' /etc/php-fpm.d/www.conf # check current values
sudo sed -i 's/^user = apache/user = nginx/; s/^group = apache/group = nginx/' \
/etc/php-fpm.d/www.conf
Also ensure the socket owner lines are nginx (uncomment if prefixed with ;):
listen.owner = nginx
listen.group = nginx
Then enable and start:
sudo systemctl enable --now php-fpm
ps aux | grep '[p]hp-fpm' # workers should show the nginx user
4. MariaDB
Install and secure
sudo dnf install -y mariadb105-server
sudo systemctl enable --now mariadb
sudo mariadb-secure-installation
Create the database, user, and grant
Use a heredoc rather than typing into the interactive monitor:
sudo mariadb <<'EOF'
CREATE DATABASE wordpress CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'wpuser'@'localhost' IDENTIFIED BY 'STRONG_PASSWORD_HERE';
GRANT ALL PRIVILEGES ON wordpress.* TO 'wpuser'@'localhost';
FLUSH PRIVILEGES;
EOF
Gotcha — don’t paste a block whose first line is
sudo mariadb. When the first pasted line launches the interactive monitor, terminal input buffering races: the SQL runs inside the monitor, but leftover lines spill back to bash afterward as-bash: CREATE: command not found. Harmless, but confusing. The heredoc above is non-interactive and avoids it entirely. The quoted'EOF'also stops the shell from expanding$, backticks, etc. in your password.
Naming.
wordpressis the database name;wpuseris the user. They’re easy to conflate because they’re created together. ReplaceSTRONG_PASSWORD_HEREwith a real password — this becomesDB_PASSWORDlater.
Verify
mysql -u wpuser -p -e "SHOW DATABASES;"
The user should see only wordpress and information_schema. The absence of mysql, performance_schema, etc. confirms the grant is correctly scoped to one database (least privilege).
5. WordPress files into the webroot
cd /tmp
rm -rf wordpress latest.tar.gz # clear any partial bits
curl -O https://wordpress.org/latest.tar.gz
tar xzf latest.tar.gz
ls -ld /tmp/wordpress # MUST print a directory before continuing
then:
sudo mkdir -p /var/www/wordpress
sudo cp -a /tmp/wordpress/. /var/www/wordpress/
ls /var/www/wordpress # wp-admin, wp-content, wp-includes, etc.
Gotcha — verify the source before copying. The WordPress tarball extracts to a top-level
wordpress/folder. If thecurl/tarstep is skipped or fails,cp -a /tmp/wordpress/.errors withcannot stat ... No such file or directory. Thels -ldcheck catches a missing source immediately instead of mid-copy.
6. Ownership and permissions
sudo chown -R nginx:nginx /var/www/wordpress
sudo find /var/www/wordpress -type d -exec chmod 755 {} \;
sudo find /var/www/wordpress -type f -exec chmod 644 {} \;
This aligns file ownership with the PHP-FPM user you set in Section 3.
Tradeoff.
nginx:nginxownership lets WordPress self-update core, install plugins/themes, and handle uploads with no FTP prompt — convenient, but a compromised PHP process can then write to core files. The hardened alternative: own the tree asec2-user:nginx, make onlywp-contentgroup-writable, and leave core read-only to the web server. That’s more secure but breaks core auto-updates (you run them yourself). For a single site you actively maintain,nginx:nginxis the pragmatic default — just know which model you chose.
7. nginx server block
Create /etc/nginx/conf.d/wordpress.conf:
sudo nano /etc/nginx/conf.d/wordpress.conf
Then paste this into the editor (change example.com):
server {
listen 80;
server_name example.com www.example.com;
root /var/www/wordpress;
index index.php;
client_max_body_size 64M; # see Section 10 to raise for large uploads
location / {
try_files $uri $uri/ /index.php?$args; # pretty permalinks
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php-fpm/www.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
}
# don't serve hidden files or expose wp-config
location ~ /\. { deny all; }
location = /wp-config.php { deny all; }
location = /xmlrpc.php { deny all; } # drop this line if you need XML-RPC
# static asset caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires max;
log_not_found off;
}
}
Validate and reload:
sudo nginx -t && sudo systemctl reload nginx
Confirm the
fastcgi_passsocket matches the actual PHP-FPM socket (/run/php-fpm/www.sockon AL2023). A socket-path or user mismatch is the classic cause of502 Bad Gateway.
8. Run the WordPress installer
Visit http://example.com/ and complete the browser installer. There are two distinct sets of credentials — keep them separate.
Database connection screen (these are your MariaDB credentials from Section 4):
| Field | Value |
|---|---|
| Database Name | wordpress |
| Username | wpuser |
| Password | the strong password you set |
| Database Host | localhost |
| Table Prefix | wp_ |
Changing the table prefix (e.g.
wp_x7k2_) is a minor hardening step, but it must be done at install time — it’s baked into the table names.
Welcome / site-info screen (your WordPress admin login — a separate account; do not reuse the database credentials):
- Don’t use
adminas the username — it’s the first thing bots try. - Use a strong password different from the database password.
- Use a real email address (your password-reset path).
If the installer reaches the welcome screen, the database connection works. An “Error establishing a database connection” instead points back to the host, user, or password — not to WordPress.
The installer auto-generates unique security salts when it writes wp-config.php. (If you ever create wp-config.php by hand, pull fresh salts from https://api.wordpress.org/secret-key/1.1/salt/.)
Prefer the command line? With WP-CLI installed (Section 12) you can do the config and install steps with
wp config createandwp core installinstead of the browser.
9. Fix the plugin/theme “FTP credentials” prompt
If installing a plugin/theme prompts for FTP, it’s not a permissions problem — do not chmod 777. The prompt means the user PHP runs as doesn’t match the user that owns the files; WordPress falls back to FTP when they differ.
- Confirm the users match (this is the Section 3 alignment):
bash
ps aux | grep '[p]hp-fpm' # should be nginx, not apache
ls -ld /var/www/wordpress/wp-content # should be nginx nginx
If php-fpm still shows apache, re-apply the www.conf edit from Section 3 and sudo systemctl restart php-fpm.
- Tell WordPress to write directly by adding one line to
wp-config.php:
bash
sudo sed -i "/wp-settings\.php/i define( 'FS_METHOD', 'direct' );" \
/var/www/wordpress/wp-config.php
(Or add define( 'FS_METHOD', 'direct' ); by hand, anywhere above the /* That's all, stop editing! */ line.)
Order matters.
FS_METHOD directworks because nginx legitimately owns and can write the files. Setting it without fixing the user alignment first just swaps the FTP prompt for a “could not create directory” error. Align the user, then set the constant. Permissions stay at 755/644 throughout — no 777.
10. Raising the upload size limit
WordPress reports a small max upload (often 2 MB) because that’s PHP’s default. Raising it means lining up several ceilings — the smallest one always wins:
| Setting | Layer | Governs |
|---|---|---|
upload_max_filesize | PHP | size of a single uploaded file |
post_max_size | PHP | size of the whole request body (must be ≥ upload_max_filesize) |
max_input_time | PHP | how long PHP will spend receiving the upload — the commonly-missed one |
client_max_body_size | nginx | rejects larger bodies with 413 before PHP sees them |
Find your PHP config scan dir (works for the default or pinned install):
php --ini # note "Scan for additional .ini files in" — usually /etc/php.d/
Drop in your overrides (isolated, and survives package updates):
sudo tee /etc/php.d/99-uploads.ini > /dev/null <<'EOF'
upload_max_filesize = 64M
post_max_size = 64M
memory_limit = 256M
max_execution_time = 300
max_input_time = 300
EOF
sudo systemctl restart php-fpm # REQUIRED — PHP reads config at FPM startup
php -i | grep -E 'upload_max_filesize|post_max_size|max_input_time' # confirm they're live
The two most common reasons a change “doesn’t take”: you forgot to restart php-fpm (so the drop-in isn’t live), or you raised
upload_max_filesizebut notpost_max_size— the effective limit is the min of the two, so the lower one still caps you.
Then raise the nginx side. For large files it isn’t only body size — a long upload or a slow backend response can also hit a timeout:
client_max_body_size 300M;
client_body_timeout 300s;
fastcgi_read_timeout 300s;
sudo nginx -t && sudo systemctl reload nginx
Plugins that do their own chunked uploads — notably All-in-One WP Migration — don’t honor these settings the way you’d expect, and the free version has a hard restore cap that no config can lift. See Appendix D before fighting the limits.
11. HTTPS with Let’s Encrypt
With DNS already pointing at the instance (Section 1):
sudo dnf install -y certbot python3-certbot-nginx
sudo certbot --nginx -d example.com -d www.example.com
Certbot rewrites the server block for 443, adds the HTTP→HTTPS redirect, and installs a renewal timer. Verify:
systemctl list-timers | grep certbot
sudo certbot renew --dry-run
12. Post-setup hardening and maintenance
Bitnami isn’t holding these for you anymore, so set them up early:
- WP-CLI — handy for migrations, search-replace, DB import/export, and bulk admin. Requires
php-cli(Section 3):
curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
chmod +x wp-cli.phar && sudo mv wp-cli.phar /usr/local/bin/wp
wp --info
On this box, wp needs to read wp-config.php (owned nginx, mode 640), so run it as root with sudo wp ... --allow-root for tasks like DB import.
- Swap file — on a small instance (e.g.
t3.micro), MariaDB + PHP-FPM will OOM under load without swap:
sudo dd if=/dev/zero of=/swapfile bs=1M count=2048
sudo chmod 600 /swapfile && sudo mkswap /swapfile && sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab
That’s it! Enjoy your new box.
