How to Deploy WordPress on a VPS
Updated Oct 2026
verified on Ubuntu 26.04 · Oct 2026Self-host WordPress on a small VPS — the PHP CMS behind a large share of the web, run with MySQL in Docker Compose behind Caddy HTTPS, set up from the command line, with a restore-tested backup and a pinned-image upgrade path.
- A VPS with 1 GB+ RAM (WordPress's own floor is 1 GB; MySQL, Docker and the reverse proxy share it, so 2 GB is kinder to a busy site)
- A fresh Ubuntu 24.04 or 26.04 server with root/sudo SSH access
- A domain or subdomain you can point at the server
What WordPress is
WordPress is the most widely used content management system on the web: a blog engine that grew into a general-purpose CMS with themes, a block editor, tens of thousands of plugins, and a REST API. It is written in PHP, stores its data in MySQL, is licensed GPL-2.0-or-later and is rated 1 / 5 to deploy. Self-hosting it means you own the content, the plugins you choose and the bill, and you also own the updates and the security patching, which is the part this guide takes seriously.
This guide runs the official Docker images with Docker Compose: WordPress, MySQL and Caddy for automatic HTTPS, with a command-line setup so the admin account is created by you, not by whoever finds the install screen first. It picks up from "you have a server" and ends with a site on HTTPS, a restore-tested backup and an upgrade routine. For help choosing a provider and tier, see Best VPS for self-hosting and the Best VPS for WordPress ranking.
Server sizing
On our test box the WordPress and MySQL containers together idled at roughly 390 MB of RAM, and the images and a fresh install took about 2.4 GB of disk. Add Docker and Caddy, and 1 GB is the practical floor; 2 GB gives room for plugins, an image-heavy media library and traffic spikes. PHP's memory use grows with each active plugin, and WordPress sites that do more than serve pages (WooCommerce, page builders) want 2 to 4 GB.
Disk grows with the media library (wp-content/uploads) and the database. Size
the plan for your uploads, and expect backups to need about the same again.
Prepare the server
Update the system and install the few tools this guide uses:
sudo apt-get update && sudo apt-get -y upgrade
sudo apt-get install -y curl openssl ufw dnsutils
If you're logged in as root, create a non-root sudo user and continue as that user:
adduser deploy
usermod -aG sudo deploy
Then open only SSH and the two ports the reverse proxy needs:
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw --force enable
WordPress will be reachable only through the reverse proxy. That matters on a
Docker host: a published container port goes around ufw entirely, so the
Compose file below binds WordPress to 127.0.0.1, publishes nothing for MySQL,
and Caddy becomes the only public door.
Paid link — we earn a commission if you shop through it.
Install Docker
WordPress is installed with Docker Compose. If Docker isn't on the server yet, the convenience script installs Docker Engine and the Compose plugin (the Docker and Compose on Ubuntu guide covers the details):
command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker "$USER"
sudo docker compose version
The group change applies on your next login; until then the commands below use
sudo docker.
Install WordPress
Create a project directory and generate the database password and your
hostname into a .env file that Compose reads automatically. Set your
hostname first (this guide uses blog.example.com; replace it with yours). The
guard keeps the step safe to re-run: regenerating the password on an existing
install would lock WordPress out of its own database.
mkdir -p ~/wordpress && cd ~/wordpress
DOMAIN=blog.example.com
[ -f .env ] || printf 'DB_PASS=%s\nDOMAIN=%s\n' "$(openssl rand -hex 16)" "$DOMAIN" > .env
grep -c DB_PASS .env
Now the Compose file. It follows the Docker Hub example for the official
images, with three changes: the image tags are pinned (so an upgrade is a
deliberate edit, not a surprise), the database runs on MySQL's long-term
support release, and WordPress binds to loopback with Caddy in front. A
wpcli service, which only runs on demand, gives you WP-CLI for setup and
maintenance. The official WordPress image already trusts the https header
Caddy sends, so the site does not loop between HTTP and HTTPS:
cd ~/wordpress
cat > docker-compose.yml <<'EOF'
services:
wordpress:
image: wordpress:7.1.3-apache
restart: always
ports: ["127.0.0.1:8080:80"]
depends_on:
db:
condition: service_healthy
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: ${DB_PASS}
WORDPRESS_DB_NAME: wordpress
volumes: ["wordpress:/var/www/html"]
db:
image: mysql:8.4
restart: always
environment:
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: ${DB_PASS}
MYSQL_RANDOM_ROOT_PASSWORD: "1"
volumes: ["db:/var/lib/mysql"]
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u$$MYSQL_USER -p$$MYSQL_PASSWORD --silent"]
start_period: 30s
interval: 10s
timeout: 5s
retries: 10
wpcli:
image: wordpress:cli
profiles: ["tools"]
depends_on:
db:
condition: service_healthy
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: ${DB_PASS}
WORDPRESS_DB_NAME: wordpress
volumes: ["wordpress:/var/www/html"]
caddy:
image: caddy:2
restart: always
ports: ["80:80", "443:443"]
environment:
DOMAIN: ${DOMAIN}
volumes: ["./Caddyfile:/etc/caddy/Caddyfile:ro", "caddy_data:/data"]
depends_on: [wordpress]
volumes:
wordpress:
db:
caddy_data:
EOF
cat > Caddyfile <<'EOF'
{$DOMAIN} {
encode zstd gzip
reverse_proxy wordpress:80
}
EOF
Start it and wait for WordPress to answer. The first boot copies WordPress into
its volume and the database initializes, so give it a moment. Until the site is
set up, WordPress redirects every request to its installer, which is what a
302 or 200 here means:
cd ~/wordpress
sudo docker compose up -d
for i in $(seq 1 40); do
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/wp-admin/install.php)
[ "$code" = "200" ] && break
sleep 3
done
echo "WordPress installer answered HTTP $code"
sudo docker compose ps
HTTP 200 and the db container healthy means the stack works. If it never
answers, jump to Troubleshooting.
HTTPS + domain
Create an A record for your site's hostname (say blog.example.com)
pointing at the server's public IP, and confirm it resolves before Caddy tries
to get a certificate:
dig +short blog.example.com
Caddy is already running. It requests the certificate as soon as the name resolves and renews it on its own (see Automatic HTTPS with Caddy for how that works). Check that HTTPS answers:
. ~/wordpress/.env
sleep 15
curl -s -o /dev/null -w 'HTTPS status %{http_code}, certificate check %{ssl_verify_result}\n' \
"https://$DOMAIN/wp-admin/install.php"
A ssl_verify_result of 0 means the certificate verified. Do this before
finishing the setup, so WordPress is created with its https address from the
start.
First login and hardening
Until setup is finished, anyone who finds the address can run the installer and become the administrator. Finish it immediately, from the terminal, so you choose the account. This sets the site address, creates the administrator with a random password and prints it once:
cd ~/wordpress
. ./.env
WP_ADMIN_PASS=$(openssl rand -hex 12)
sudo docker compose run --rm wpcli wp core install \
--url="https://$DOMAIN" --title="My Site" \
--admin_user=siteadmin --admin_password="$WP_ADMIN_PASS" \
--admin_email="admin@$DOMAIN" --skip-email
echo "admin password: $WP_ADMIN_PASS"
Write the password down, then open https://blog.example.com/wp-admin, sign in
as siteadmin, and:
- Change the admin email to an address you control, and set the real site title and tagline under Settings → General.
- Under Settings → Permalinks, choose "Post name" for readable URLs.
- Remove themes and plugins you will not use. Every installed plugin is a possible vulnerability, active or not, and unmaintained plugins are the usual way WordPress sites get compromised.
- Install a transactional-mail plugin or configure SMTP so password resets and comment notifications arrive.
- Decide on spam control for comments before you publish, not after.
Confirm WordPress is serving the site from the HTTPS address you set:
cd ~/wordpress
sudo docker compose run --rm wpcli wp option get siteurl
curl -s -o /dev/null -w 'front page HTTP %{http_code}\n' "https://$DOMAIN/"
Backups
State lives in two named volumes: the MySQL data, and the wordpress volume
holding wp-content (uploads, plugins, themes) and wp-config.php. Back up
both. Start with a database dump, which is portable across MySQL versions:
cd ~/wordpress
sudo docker compose exec -T db sh -c \
'mysqldump --single-transaction --no-tablespaces -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
> ~/wordpress-db-$(date +%F).sql
chmod 600 ~/wordpress-db-$(date +%F).sql
test -s ~/wordpress-db-$(date +%F).sql && ls -lh ~/wordpress-db-*.sql
Then archive the site files and the Compose configuration. These archives hold wp-config.php and .env, which contain passwords, so the commands also restrict them to their owner. The volume name
is the project directory name plus the volume name, here wordpress_wordpress:
cd ~/wordpress
sudo docker run --rm -v wordpress_wordpress:/data -v "$HOME":/backup alpine \
tar czf "/backup/wordpress-files-$(date +%F).tar.gz" -C /data .
sudo tar czf ~/wordpress-config-$(date +%F).tar.gz docker-compose.yml Caddyfile .env
sudo chmod 600 ~/wordpress-files-*.tar.gz ~/wordpress-config-*.tar.gz
ls -lh ~/wordpress-files-*.tar.gz ~/wordpress-config-*.tar.gz
Copy these files off the server, to object storage, another machine, anywhere that survives this VPS dying, and run the set from cron. A backup you have never restored is a guess, so prove the dump loads. This starts a throwaway MySQL container, imports the dump and counts the users:
cd ~
sudo docker rm -f wp-restore-test >/dev/null 2>&1
sudo docker run -d --name wp-restore-test -e MYSQL_ROOT_PASSWORD=scratch mysql:8.4 >/dev/null
M="sudo docker exec -e MYSQL_PWD=scratch wp-restore-test"
for i in $(seq 1 40); do
$M mysqladmin ping -h 127.0.0.1 -uroot --silent && break
sleep 3
done
$M mysql -uroot -e "CREATE DATABASE wordpress"
sudo docker exec -i -e MYSQL_PWD=scratch wp-restore-test mysql -uroot wordpress < ~/wordpress-db-$(date +%F).sql
$M mysql -uroot -N -e "SELECT COUNT(*) FROM wordpress.wp_users"
sudo docker rm -f wp-restore-test >/dev/null
It should print 1. For a real restore onto a new server, install Docker,
unpack the config archive into ~/wordpress, start only the database
(sudo docker compose up -d db), pipe the dump into it with
sudo docker compose exec -T db sh -c 'mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"',
unpack the files archive into the wordpress volume, then start everything.
Upgrades
There are two kinds of update. WordPress core, plugins and themes update from the dashboard or from WP-CLI. Take a backup first, then:
cd ~/wordpress
sudo docker compose run --rm wpcli wp plugin update --all
sudo docker compose run --rm wpcli wp theme update --all
sudo docker compose run --rm wpcli wp core update-db
The container images are pinned in docker-compose.yml. To move to a new
WordPress release, check the
release notes, take a backup,
change the wordpress image tag (and keep wordpress:cli current by pulling
it), then pull and recreate:
cd ~/wordpress
sudo docker compose pull
sudo docker compose up -d
sudo docker compose run --rm wpcli wp core update-db
WordPress upgrades its own database schema, which is one-way, so the backup
comes first. Keep the mysql tag on its long-term-support line; moving to a new
MySQL major is a dump-and-restore job, not a tag bump.
Troubleshooting
"Error establishing a database connection". The database is not ready or
the password no longer matches. Check sudo docker compose ps for db
healthy, and read sudo docker compose logs db. The usual cause is a
regenerated .env after the db volume already existed with the old
password: restore the original .env, or remove the volumes if the install
holds no data yet.
The site redirects in a loop, or shows mixed-content warnings. The site
address stored in WordPress does not match what visitors use. Set it with
sudo docker compose run --rm wpcli wp option update home "https://blog.example.com"
and the same for siteurl.
Caddy never gets a certificate. Confirm dig +short blog.example.com
returns the server's IP and that your provider's network-level firewall, not
just ufw, allows inbound 80 and 443. A blocked port 80 silently breaks the
ACME challenge.
Uploads fail on large files. PHP's default upload limit is small. Add a
small uploads.ini with upload_max_filesize and post_max_size to the
wordpress service's volumes, mounted into /usr/local/etc/php/conf.d/.
You are locked out of the admin account. Reset it from the terminal with
sudo docker compose run --rm wpcli wp user update siteadmin --user_pass=NEWPASSWORD.
Verification + next steps
Not exercised in our test run: the new-user step and the DNS check against your real domain (we ran on a throwaway box with a temporary hostname).
You're done when https://blog.example.com loads with a valid certificate,
you can sign in at /wp-admin with a strong password, the installer screen no
longer appears, docker compose ps shows every service up after a reboot
(restart: always brings them back), and you have restored a database backup
at least once.
From there, pick a theme, install a small set of well-maintained plugins (a caching plugin, a backup plugin that ships off-server, an SEO plugin) and add real content. For the ranked server picks and the app's catalog details, see Best VPS for WordPress, Best VPS for self-hosting and the WordPress entry.