Self-host guide

How to Self-Host WordPress on a VPS

Run WordPress on Ubuntu 24.04 with Docker Compose, MySQL 8.0 and Caddy for automatic HTTPS, then harden, back up and update it on a VeloxaHost VPS.

WordPress is the PHP and MySQL content management system behind many blogs and business sites. This guide runs it on Ubuntu 24.04 with Docker Compose, using the compose example from the WordPress Docker Official Image: a wordpress container, a mysql:8.0 database, named volumes and passwords kept in Docker secrets. Caddy sits in front and gets a Let's Encrypt certificate automatically.

Prefer one click? Deploy WordPress from the VeloxaHost marketplace and skip the manual steps; this guide is for when you want to run every command yourself.

What you need

  • A server running Ubuntu 24.04 LTS with root access. Docker supports 24.04 (Noble) in its apt repository.
  • A domain name you can point at the server.
  • A dedicated IPv4 address, so Caddy can answer on ports 80 and 443.
  • An SMTP account with your email provider, because the WordPress image cannot send email on its own.

WordPress recommends PHP 8.3 or greater, MySQL 8.0 or greater (or MariaDB 10.11 or greater) and HTTPS. The image's default tag currently ships WordPress 7.1.3 on Apache with PHP 8.3, which meets that.

On VeloxaHost, choose the Ubuntu 24.04 LTS image when you create the server. A Veloxa 1 (1 vCPU, 2 GB RAM, 50 GB disk) at $8 a month, billed at $0.02 an hour and capped at the monthly price, runs a typical site. SSH or the browser terminal on the server's page both work.

Install WordPress

  1. Give the server a dedicated IPv4 address. Ports 80 and 443 are not available on the shared IPv4 address where each VeloxaHost server gets its block of ports. A dedicated IPv4 address is a paid add-on (the console shows the current price): pick it as "Network access" when you create the server, or take one later from the IP addresses page. If none is free in the region, the request is refused. Add the address to the interface (check the name with ip addr) and to the network configuration so it survives a reboot, then point your domain's A record at it. IPv6 is not available in US East yet.

    ip addr add 198.51.100.5/32 dev eth0
    
  2. Install Docker Engine and the Compose plugin from Docker's apt repository. First remove any conflicting packages; if apt says none are installed, carry on.

    sudo apt remove $(dpkg --get-selections docker.io docker-compose docker-compose-v2 docker-doc docker-buildx podman-docker containerd runc | cut -f1)
    
    # Add Docker's official GPG key:
    sudo apt update
    sudo apt install ca-certificates curl
    sudo install -m 0755 -d /etc/apt/keyrings
    sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
    sudo chmod a+r /etc/apt/keyrings/docker.asc
    
    # Add the repository to Apt sources:
    sudo tee /etc/apt/sources.list.d/docker.sources <<EOF
    Types: deb
    URIs: https://download.docker.com/linux/ubuntu
    Suites: $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
    Components: stable
    Architectures: $(dpkg --print-architecture)
    Signed-By: /etc/apt/keyrings/docker.asc
    EOF
    
    sudo apt update
    sudo apt install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
    sudo docker run hello-world
    
  3. Create a project directory and a random database password. Compose mounts the file into the containers as a secret at /run/secrets/db_password.

    mkdir -p /opt/wordpress/conf
    cd /opt/wordpress
    openssl rand -hex 32 > db_password.txt
    
  4. Write /opt/wordpress/compose.yaml: the image's compose example with the password moved into a secret, as Docker's secrets guide shows, plus Caddy from the Caddy image docs. WordPress publishes no port; Caddy reaches it on the Compose network.

    services:
    
      wordpress:
        image: wordpress
        restart: always
        depends_on:
          - db
        environment:
          WORDPRESS_DB_HOST: db
          WORDPRESS_DB_USER: wordpress
          WORDPRESS_DB_PASSWORD_FILE: /run/secrets/db_password
          WORDPRESS_DB_NAME: wordpress
          WORDPRESS_TABLE_PREFIX: wpsite_
          WORDPRESS_CONFIG_EXTRA: |
            define( 'DISALLOW_FILE_EDIT', true );
            define( 'FORCE_SSL_ADMIN', true );
        secrets:
          - db_password
        volumes:
          - wordpress:/var/www/html
    
      db:
        image: mysql:8.0
        restart: always
        environment:
          MYSQL_DATABASE: wordpress
          MYSQL_USER: wordpress
          MYSQL_PASSWORD_FILE: /run/secrets/db_password
          MYSQL_RANDOM_ROOT_PASSWORD: '1'
        secrets:
          - db_password
        volumes:
          - db:/var/lib/mysql
    
      caddy:
        image: caddy:2
        restart: unless-stopped
        cap_add:
          - NET_ADMIN
        ports:
          - "80:80"
          - "443:443"
          - "443:443/udp"
        volumes:
          - ./conf:/etc/caddy
          - caddy_data:/data
          - caddy_config:/config
    
    secrets:
      db_password:
        file: ./db_password.txt
    
    volumes:
      wordpress:
      db:
      caddy_data:
      caddy_config:
    
  5. Write /opt/wordpress/conf/Caddyfile with your domain. Caddy's reverse_proxy sets the X-Forwarded-Proto header, and the WordPress image adds the matching HTTP_X_FORWARDED_PROTO check to wp-config.php, so WordPress knows visitors are on HTTPS without a redirect loop.

    example.com {
        reverse_proxy wordpress:80
    }
    
  6. Start the stack and watch the logs. Caddy requests the certificate as soon as it starts.

    cd /opt/wordpress
    docker compose up -d
    docker compose logs -f
    

First login

Open https://example.com in your browser. WordPress shows its well-known installer, which its docs say takes less than five minutes. Enter the site title, a username, a password (twice) and your email address. Leave the search engine checkbox unchecked unless you want search engines to stay away for now. Then log in at https://example.com/wp-admin.

Run the installer on the real HTTPS domain, not an IP and port, since WordPress saves its address at install time. Check WordPress Address and Site Address under Settings, then General.

Set up outgoing mail next, or password resets will not arrive: the image's docs say it cannot send email. Install an SMTP plugin and use your provider's port 587 or 465, since VeloxaHost blocks outbound TCP port 25.

Secure it

Attach a VeloxaHost firewall. It is network-level and stateful, and once attached it drops inbound traffic no rule allows. In the console, go to Firewalls, then New Firewall, then Add Rule:

  • allow TCP 22 from YOUR.IP/32 only
  • allow TCP 80 and 443 from 0.0.0.0/0
  • allow UDP 443 from 0.0.0.0/0 if you want HTTP/3

Attach it on the server's page. Docker's docs warn that ports Docker publishes bypass ufw, so rely on the VeloxaHost firewall here. MySQL publishes no port at all, so only containers in this Compose project can reach it.

The compose file already applies several items from WordPress's hardening guide: DISALLOW_FILE_EDIT removes the theme and plugin editors from the dashboard, FORCE_SSL_ADMIN keeps logins on HTTPS, and the table prefix is not the default wp_. The prefix only takes effect on a fresh install. Beyond that, the guide recommends:

  • a strong, unique password and an admin username other than admin
  • two-step authentication for every administrator
  • keeping core, themes and plugins updated, and deleting plugins you do not use
  • downloading WordPress itself only from wordpress.org

Back it up

A WordPress backup has two parts, the database and the files. Core files can be replaced from a fresh download, so the files that matter are wp-content (themes, plugins, uploads), wp-config.php and .htaccess. Dump the database first, then archive the files:

cd /opt/wordpress
docker compose exec -T db sh -c 'exec mysqldump --add-drop-table -u wordpress -p"$(cat /run/secrets/db_password)" wordpress' > wordpress-db.sql
docker compose exec -T wordpress tar czf - -C /var/www/html wp-content wp-config.php .htaccess > wordpress-files.tar.gz

To restore, put the files back first, then import the database:

docker compose exec -T wordpress tar xzf - -C /var/www/html < wordpress-files.tar.gz
docker compose exec -T db sh -c 'exec mysql -u wordpress -p"$(cat /run/secrets/db_password)" wordpress' < wordpress-db.sql

Keep db_password.txt with your backups, or the restored database will not match your secret.

On VeloxaHost, schedule the rest. Automatic snapshots on the server page run every hour, 6 hours, 12 hours, daily or weekly, kept 1, 3, 7, 14 or 30 days; restoring one overwrites the server's current state. The Backups page schedules full server backups every 6 hours, 12 hours, daily or weekly, keeping 1 to 10, and lets you download one as an off-site copy.

Update it

There are two layers. WordPress core, themes and plugins live in the wordpress volume, which the image docs call self-managing after the first start. Minor and security releases install automatically, and so do major releases on installs made since WordPress 5.6. Update themes and plugins from the dashboard.

The containers are the second layer: Apache and PHP, MySQL 8.0 and Caddy 2. Take a snapshot in the console, then:

cd /opt/wordpress
docker compose pull
docker compose up -d
docker image prune

A newer wordpress image does not replace the WordPress files already in the volume; the entrypoint only copies WordPress in when it finds none.

Troubleshooting

"Error establishing a database connection". On first start, MySQL may still be initializing; wait and check docker compose logs db. If you changed db_password.txt after the first start, the database keeps the old password, because MySQL ignores its setup variables once the data directory exists.

No certificate, or the site will not load. Run docker compose logs caddy. The A record must point at the dedicated IPv4, and ports 80 and 443 must be open, since Caddy uses them for the ACME challenges.

Redirect loops or the wrong address. WordPress has a stored URL that does not match your domain. Fix it under Settings, then General, or add define( 'WP_HOME', 'https://example.com' ); and define( 'WP_SITEURL', 'https://example.com' ); to WORDPRESS_CONFIG_EXTRA and run docker compose up -d.

FAQ

Which VeloxaHost size fits WordPress?

A Veloxa 1 (1 vCPU, 2 GB RAM, 50 GB disk) at $8 a month, billed at $0.02 an hour and capped at the monthly price, fits most sites with this stack. A busy shop may need a Veloxa 2 (2 vCPU, 4 GB RAM, 80 GB disk) at $15 a month, billed at $0.03 an hour.

What license is WordPress under?

WordPress is released under the GNU General Public License, version 2 or later (GPLv2 or later). You can use, change and redistribute it under those terms.

Does pulling a new image update WordPress itself?

No. Pulling updates Apache, PHP and the rest of the image. WordPress core lives in the wordpress volume and updates itself, automatically or from the dashboard.

Why does WordPress need an SMTP plugin here?

The image's docs say it cannot send email. An SMTP plugin sends through your provider instead. Use port 587 or 465, because VeloxaHost blocks outbound port 25 by default.

Can I run WordPress without a dedicated IPv4 address?

Not with this setup. Caddy's automatic HTTPS needs ports 80 and 443, which the shared address does not offer. You could forward a shared port and get a certificate through a DNS challenge instead, but every link to the site would then carry the port number, so a dedicated IPv4 address is the practical route.

Commands and settings checked on 2026-10-18 against the official documentation: hub.docker.com, github.com, docs.docker.com, caddyserver.com, wordpress.org, developer.wordpress.org. Software changes; if a step no longer matches, the vendor's docs win.

Ready to deploy?

Start a free trial, or compare plans to match your team's scale.