Skip to content

How to Deploy WordPress on a VPS

Updated Oct 2026

verified on Ubuntu 26.04 · Oct 2026
We earn commissions when you shop through the links below. Full disclosure →

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

Before you start
  • 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
Need a box for this guide? Kamatera's free tier lets you spin one up now.Start free on Kamatera → (opens in new tab)

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.

Where to host itaffiliate disclosure
Hetzner Cloudrun it on
2 vCPU · 4 GB RAM · 80 GB SSD · $23.59/mo
Get Hetzner Cloud (opens in new tab)
Kamaterafree trial
1 vCPU · 1 GB RAM · 20 GB SSD · $4.00/mo
Start free on Kamatera → (opens in new tab)
DigitalOceanalso works on
1 vCPU · 1 GB RAM · 25 GB SSD · $6.00/mo
Deploy on DigitalOcean → (opens in new tab)

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:

  1. Change the admin email to an address you control, and set the real site title and tagline under Settings → General.
  2. Under Settings → Permalinks, choose "Post name" for readable URLs.
  3. 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.
  4. Install a transactional-mail plugin or configure SMTP so password resets and comment notifications arrive.
  5. 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.

Next steps

How to self-host WordPress →More self-hosted cms & blogging tools →Best VPS for WordPress →Automatic HTTPS with Caddy →Run Claude Code with Ollama on Your Own VPS →Deploy Coolify on a VPS →How to Deploy Actual Budget on a VPS →How to Deploy AFFiNE on a VPS →How to Deploy AnythingLLM on a VPS →How to Deploy Appwrite on a VPS →How to Deploy Audiobookshelf on a VPS →How to Deploy Authelia on a VPS →How to Deploy authentik on a VPS →How to Deploy Baserow on a VPS →How to Deploy Beszel on a VPS →How to Deploy Bitwarden on a VPS →How to Deploy BookStack on a VPS →How to Deploy CapRover on a VPS →How to Deploy Checkmate on a VPS →How to Deploy copyparty on a VPS →How to Deploy Directus on a VPS →How to Deploy Discourse on a VPS →How to Deploy docker-mailserver on a VPS →How to Deploy Docmost on a VPS →How to Deploy Dokku on a VPS →How to Deploy Dokploy on a VPS →How to Deploy Firefly III on a VPS →How to Deploy Flarum on a VPS →How to Deploy Forgejo on a VPS →How to Deploy Gatus on a VPS →How to Deploy Ghost on a VPS →How to Deploy Ghostfolio on a VPS →How to Deploy Gitea on a VPS →How to Deploy a GitHub Actions Self-Hosted Runner on a VPS →How to Deploy GitLab on a VPS →How to Deploy GlitchTip on a VPS →How to Deploy Grafana on a VPS →How to Deploy Graylog on a VPS →How to Deploy Headscale on a VPS →How to Deploy Healthchecks on a VPS →How to Deploy Home Assistant on a VPS →How to Deploy Immich on a VPS →How to Deploy Infisical on a VPS →How to Deploy Invoice Ninja on a VPS →How to Deploy Jan on a VPS →How to Deploy Jellyfin on a VPS →How to Deploy Kanboard on a VPS →How to Deploy Karakeep on a VPS →How to Deploy Keycloak on a VPS →How to Deploy Leantime on a VPS →How to Deploy LibreChat on a VPS →How to Deploy Linkwarden on a VPS →How to Deploy LocalAI on a VPS →How to Deploy Mailcow on a VPS →How to Deploy Mailu on a VPS →How to Deploy Matomo on a VPS →How to Deploy Mattermost on a VPS →How to Deploy Meilisearch on a VPS →How to Deploy Memos on a VPS →How to Deploy a Minecraft Server on a VPS →How to Deploy n8n on a VPS →How to Deploy Navidrome on a VPS →How to Deploy NetBird on a VPS →How to Deploy Netdata on a VPS →How to Deploy Nextcloud on a VPS →How to Deploy Next.js to a VPS →How to Deploy Nginx Proxy Manager on a VPS →How to Deploy NocoDB on a VPS →How to Deploy ntfy on a VPS →How to Deploy Odoo on a VPS →How to Deploy Ollama on a VPS →How to Deploy ONLYOFFICE Docs on a VPS →How to Deploy Open WebUI on a VPS →How to Deploy OpenHands on a VPS →How to Deploy OpenObserve on a VPS →How to Deploy OpenProject on a VPS →How to Deploy Outline on a VPS →How to Deploy Pangolin on a VPS →How to Deploy Paperless-ngx on a VPS →How to Deploy Passbolt on a VPS →How to Deploy Plane on a VPS →How to Deploy Plausible Analytics on a VPS →How to Deploy Plex Media Server on a VPS →How to Deploy Pocket ID on a VPS →How to Deploy PocketBase on a VPS →How to Deploy Prometheus on a VPS →How to Deploy Psono on a VPS →How to Deploy Radarr on a VPS →How to Deploy Rocket.Chat on a VPS →How to Deploy SigNoz on a VPS →How to Deploy Sonarr on a VPS →How to Deploy Stalwart on a VPS →How to Deploy Stirling-PDF on a VPS →How to Deploy Supabase on a VPS →How to Deploy Synapse on a VPS →How to Deploy Taiga on a VPS →How to Deploy TeamPass on a VPS →How to Deploy Tinyauth on a VPS →How to Deploy Traefik on a VPS →How to Deploy Trilium on a VPS →How to Deploy Twenty CRM on a VPS →How to Deploy Umami on a VPS →How to Deploy Uptime Kuma on a VPS →How to Deploy Vaultwarden on a VPS →How to Deploy Vikunja on a VPS →How to Deploy wg-easy on a VPS →How to Deploy Wiki.js on a VPS →How to Deploy Zabbix on a VPS →How to Deploy Zitadel on a VPS →How to Deploy Zulip on a VPS →Docker & Compose on Ubuntu 26.04 →Building AI Workflows with n8n →Install Open WebUI with Ollama →Adding AI-Powered Insights to Plausible Analytics →Building AI-Powered Apps with Supabase and pgvector →

We use analytics cookies (Google Analytics, PostHog) to see which guides are useful. No ad networks, no cross-site tracking. See our privacy policy.