Deploying WordPress on AWS — Amazon Linux & LOCAL DB

Overview

ComponentChoice
OSAmazon Linux 2023 (AL2023)
Web servernginx
RuntimePHP-FPM (PHP 8.3 recommended)
DatabaseMariaDB (local) — RDS noted as an alternative
TLSLet’s Encrypt via Certbot

1. EC2 instance and networking

  1. Launch the AL2023 instance.
  2. 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. wordpress is the database name; wpuser is the user. They’re easy to conflate because they’re created together. Replace STRONG_PASSWORD_HERE with a real password — this becomes DB_PASSWORD later.

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 the curl/tar step is skipped or fails, cp -a /tmp/wordpress/. errors with cannot stat ... No such file or directory. The ls -ld check 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:nginx ownership 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 as ec2-user:nginx, make only wp-content group-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:nginx is 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_pass socket matches the actual PHP-FPM socket (/run/php-fpm/www.sock on AL2023). A socket-path or user mismatch is the classic cause of 502 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):

FieldValue
Database Namewordpress
Usernamewpuser
Passwordthe strong password you set
Database Hostlocalhost
Table Prefixwp_

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 admin as 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 create and wp core install instead 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.

  1. 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.

  1. 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 direct works 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:

SettingLayerGoverns
upload_max_filesizePHPsize of a single uploaded file
post_max_sizePHPsize of the whole request body (must be ≥ upload_max_filesize)
max_input_timePHPhow long PHP will spend receiving the upload — the commonly-missed one
client_max_body_sizenginxrejects 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_filesize but not post_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.