# How to Run WordPress in a Cloud Sandbox With an HTTPS URL

> Run the official wordpress and mariadb images with Docker Compose in a Sandboxer sandbox, bind WordPress to 127.0.0.1 and host it with sbx host --localhost. Install with WP-CLI using the hosted HTTPS URL as the site URL. The image already trusts X-Forwarded-Proto, so login, wp-admin and permalinks work.

Source: https://sandboxer.com/guides/run-wordpress-in-a-sandbox. Updated 2026-10-27.

**Short answer:** Run the official wordpress and mariadb images with Docker Compose in a Sandboxer sandbox, bind WordPress to 127.0.0.1 and host it with sbx host --localhost. Install with WP-CLI using the hosted HTTPS URL as the site URL. The image already trusts X-Forwarded-Proto, so login, wp-admin and permalinks work.

## What we ran

Everything below ran on production on October 27, 2026, in one `small` sandbox (2 vCPU, 4 GB): WordPress 7.1.3 from the `wordpress:7.1-php8.3-apache` image, MariaDB 11.8 and WP-CLI 2 from `wordpress:cli-2-php8.3`. The requests came from a laptop, single runs unless noted.

| Step | Result |
| - | - |
| `sbx new --type small` | 3.2 s to ready |
| `docker compose up -d` (pulls both images) | 19.8 s |
| `wp core install` | 2.0 s |
| `wp plugin install classic-editor --activate` | 3.1 s |
| Post page through the hosted URL, 20 requests | 288 ms median (279 to 583 ms) |
| Same page from inside the sandbox | 69 ms median |
| `/wp-admin/` through the hosted URL, 10 requests | 335 ms median |
| Memory used by both containers | about 220 MB |
| `sbx stop --wait` then `sbx resume` | 2.2 s stop, 1.3 s resume, `memoryRestored: true`, still logged in |
| Resume with `--type default` (boots from disk) | Both containers back in under 10 s, site and wp-admin 200 |

## 1. The Compose file

Docker and Compose are preinstalled in every sandbox. This file follows the [official WordPress image docs](https://hub.docker.com/_/wordpress) and adds three things: WordPress is published on `127.0.0.1` only, both services restart on their own, and a `cli` service runs [WP-CLI](https://wp-cli.org/) against the same files and database.

```yaml
# wp/compose.yaml
x-wp-env: &wp-env
  WORDPRESS_DB_HOST: db
  WORDPRESS_DB_NAME: wordpress
  WORDPRESS_DB_USER: wp
  WORDPRESS_DB_PASSWORD: ${DB_PASSWORD}

services:
  db:
    image: mariadb:11.8
    restart: unless-stopped
    environment:
      MARIADB_DATABASE: wordpress
      MARIADB_USER: wp
      MARIADB_PASSWORD: ${DB_PASSWORD}
      MARIADB_RANDOM_ROOT_PASSWORD: "1"
    volumes:
      - db:/var/lib/mysql
    healthcheck:
      test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
      interval: 5s
      retries: 30

  wordpress:
    image: wordpress:7.1-php8.3-apache
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
    ports:
      - "127.0.0.1:8080:80"
    environment: *wp-env
    volumes:
      - wp:/var/www/html

  cli:
    image: wordpress:cli-2-php8.3
    profiles: ["cli"]
    user: "33:33"
    depends_on:
      - wordpress
    environment:
      <<: *wp-env
      HOME: /tmp
    volumes:
      - wp:/var/www/html

volumes:
  db:
  wp:
```

Why these choices:

- `127.0.0.1:8080:80`. The sandbox has a public IPv6 address. Its firewall (`ufw`) is closed except SSH, but Docker can [bypass ufw rules](https://docs.docker.com/engine/network/packet-filtering-firewalls/#docker-and-ufw) on some setups. Binding to `127.0.0.1` keeps WordPress reachable only through the hosted URL, whatever the firewall does.
- `user: "33:33"` on `cli`. The CLI image runs as a different user ID than the Apache image, whose files belong to `www-data` (33). Without it, WP-CLI can't write plugins into the shared volume.
- The `cli` environment repeats the database variables. The image's `wp-config.php` reads them at run time, so WP-CLI needs them too.
- The `service_healthy` condition waits for MariaDB before WordPress starts. `healthcheck.sh` ships in the [mariadb image](https://hub.docker.com/_/mariadb).

## 2. Start it

Copy the folder in, write random passwords to files (they are never printed), and start the stack:

```bash
sbx new --name wordpress --type small
id=sb_p4esgbgh3g    # the id that sbx new printed

sbx scp -r ./wp $id:/home/user/
sbx exec $id --timeout 600 "cd wp && umask 077 \
  && echo DB_PASSWORD=\$(openssl rand -hex 16) > .env \
  && openssl rand -hex 12 | tr -d '\n' > admin-pass \
  && docker compose up -d --quiet-pull \
  && docker compose ps --format 'table {{.Service}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}'"
```

```text
 Container wp-db-1 Healthy
 Container wp-wordpress-1 Starting
 Container wp-wordpress-1 Started
SERVICE     IMAGE                         STATUS                   PORTS
db          mariadb:11.8                  Up 5 seconds (healthy)   3306/tcp
wordpress   wordpress:7.1-php8.3-apache   Up Less than a second    127.0.0.1:8080->80/tcp
```

## 3. Host the port before you install

WordPress stores its own address in the database. So get the URL first, then install with it.

```bash
sbx host $id 8080 --localhost --title wordpress
```

```text
{"ok":true,"type":"port.hosted","sandboxId":"sb_p4esgbgh3g","port":8080,"url":"https://crisp-copper-lime-8080.box.sandboxer.com?_token=<token>","access":"private","isProtected":true,"localhost":true}
```

`--localhost` makes the sandbox relay to `127.0.0.1:8080`, which is where Compose published it. The URL is private: keep the `_token` secret. See [Hosting](/docs/hosting) for public URLs.

## 4. Install WordPress with WP-CLI

Save this as `wp/install.sh` and copy it in. It passes the admin password from the file, so it never shows in the output:

```bash
#!/bin/bash
# usage: bash install.sh https://<subdomain>-8080.box.sandboxer.com
set -e
cd ~/wp
docker compose run -T --rm cli wp core install --url="$1" --title="Sandbox blog" \
  --admin_user=admin --admin_email=admin@example.com --skip-email \
  --admin_password="$(cat admin-pass)"
```

```bash
sbx scp wp/install.sh $id:/home/user/wp/install.sh
sbx exec $id --timeout 300 "bash wp/install.sh https://crisp-copper-lime-8080.box.sandboxer.com"
sbx exec $id "cd wp && docker compose run -T --rm cli wp option get siteurl"
```

```text
Success: WordPress installed successfully.
https://crisp-copper-lime-8080.box.sandboxer.com
```

Use the base URL, without `?_token=`. Our first try used `--prompt=admin_password` to read the password from stdin. WP-CLI echoed the password back in its output, so we switched to the form above.

Read the password with `sbx exec $id "cat wp/admin-pass"` when you log in.

## 5. The two settings that make it work behind a proxy

**siteurl and home.** WordPress redirects every request to the host in its `home` option. To show what goes wrong, we set both options to `http://localhost:8080`, as a local install would:

```text
/            301 -> https://crisp-copper-lime-8080.box.sandboxer.com:8080/
/wp-admin/   302 -> https://localhost:8080/wp-login.php?redirect_to=…&reauth=1
```

The home page sent the browser to port 8080 on the hosted name, and wp-admin sent it to `localhost`. Neither loads. If you moved a site in, fix every stored URL with [`wp search-replace`](https://developer.wordpress.org/cli/commands/search-replace/):

```bash
sbx exec $id "cd wp && docker compose run -T --rm cli wp search-replace \
  http://localhost:8080 https://crisp-copper-lime-8080.box.sandboxer.com --all-tables --report-changed-only"
```

```text
Table	Column	Replacements	Type
wp_options	option_value	2	PHP
Success: Made 2 replacements.
```

Both paths then returned 200.

**HTTPS behind the proxy.** TLS ends at the Sandboxer edge, so Apache in the sandbox sees plain HTTP. The hosting proxy keeps the original `Host` header and sends `X-Forwarded-Proto: https`. The official image's `wp-config.php` already turns that header into `$_SERVER['HTTPS'] = 'on'`:

```bash
sbx exec $id "docker compose -f wp/compose.yaml exec -T wordpress grep -n -A2 HTTP_X_FORWARDED_PROTO /var/www/html/wp-config.php"
```

```text
122:if (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && strpos($_SERVER['HTTP_X_FORWARDED_PROTO'], 'https') !== false) {
123-	$_SERVER['HTTPS'] = 'on';
124-}
```

We commented out line 123 to see what happens without it:

```text
src="http://crisp-copper-lime-8080.box.sandboxer.com/wp-includes/js/hoverintent-js.min.js?ver=2.2.1"
/wp-admin/        302 -> https://crisp-copper-lime-8080.box.sandboxer.com/wp-admin/
login POST        302 -> https://crisp-copper-lime-8080.box.sandboxer.com/wp-login.php
```

Pages linked their scripts over `http://`, which browsers block as mixed content. wp-admin redirected to itself in a loop, and the login form sent us back to the login page. If you build your own image or use another WordPress setup, add those three lines near the top of `wp-config.php`. WordPress documents the same fix in [Administration over SSL](https://developer.wordpress.org/advanced-administration/security/https/). We restored the file before going on.

## 6. Log in and open wp-admin through the URL

We checked the private URL and the login with `curl` from a laptop. `$U` is the base URL, `$T` the token, and `pass` holds the admin password:

```bash
curl -s -o /dev/null -w "no token: %{http_code}\n" $U/
curl -s -c jar -b jar -o /dev/null -w "with token: %{http_code}\n" "$U/?_token=$T"
curl -s -c jar -b jar -o /dev/null $U/wp-login.php    # sets WordPress's test cookie
curl -s -c jar -b jar -o /dev/null -w "login: %{http_code} -> %{redirect_url}\n" \
  --data-urlencode log=admin --data-urlencode "pwd@pass" \
  --data-urlencode "redirect_to=$U/wp-admin/" --data testcookie=1 $U/wp-login.php
curl -s -b jar $U/wp-admin/ | grep -o '<title>[^<]*'
```

```text
no token: 401
with token: 302
login: 302 -> https://crisp-copper-lime-8080.box.sandboxer.com/wp-admin/
<title>Dashboard &lsaquo; Sandbox blog &#8212; WordPress
```

The token request sets a cookie and redirects to `/`, so a browser needs the token only once. The home page's links all use `https://crisp-copper-lime-8080.box.sandboxer.com`.

## 7. Pretty permalinks and a plugin with WP-CLI

The image ships Apache with `mod_rewrite` and writes the standard `.htaccess`, so changing the structure is one command:

```bash
sbx exec $id --timeout 300 "cd wp
docker compose run -T --rm cli wp rewrite structure '/%postname%/'
docker compose run -T --rm cli wp post create --post_title='Hello from a sandbox' --post_status=publish --porcelain
docker compose run -T --rm cli wp plugin install classic-editor --activate
docker compose run -T --rm cli wp plugin list --fields=name,status,version"
```

```text
Success: Rewrite structure set.
Success: Rewrite rules flushed.
6
Installing Classic Editor (1.7.0)
…
Plugin 'classic-editor' activated.
Success: Installed 1 of 1 plugins.
name	status	version
akismet	inactive	5.7.2
classic-editor	active	1.7.0
hello	inactive	1.7.2
```

Through the hosted URL, `/hello-from-a-sandbox/` returned 200, `/?p=6` returned 301 to it, `/wp-json/wp/v2/posts/6` returned the post with `"link": "https://crisp-copper-lime-8080.box.sandboxer.com/hello-from-a-sandbox/"`, and `/wp-admin/plugins.php` listed Classic Editor.

The post page took 288 ms median through the URL and 69 ms median from inside the sandbox. Most of the difference is the network path from our laptop to the sandbox, which depends on where you are.

## 8. Stop and resume

```bash
sbx stop $id --wait
sbx resume $id
sbx exec $id "cat /proc/uptime; cd wp && docker compose ps --format '{{.Service}} {{.Status}}'"
```

```text
164.74 283.15
db Up 2 minutes (healthy)
wordpress Up 2 minutes
```

`sbx info` showed `memoryRestored: true`. The containers never restarted, and the same cookie jar still opened `/wp-admin/plugins.php` with no new login. The URL and token stayed the same.

A resume boots from disk when no machine with the same CPU type is free or when you resize. To test that path, we stopped again and resumed with `sbx resume $id --type default`:

```text
type default memoryRestored False resumeReason resized
2.59 6.20
db Up 9 seconds (healthy)
wordpress Up 9 seconds
```

An uptime of 2.6 seconds shows a fresh boot. Docker started with the VM and `restart: unless-stopped` brought both containers back. The post, the plugin and the login cookie all survived, because they live in the volumes and the database. See [Snapshots](/docs/snapshots) for the resume rules.

## 9. Clean up

```bash
sbx delete $id --yes
```

After the delete, `sbx info` returned `not_found` and the URL returned 404. A `small` sandbox costs $0.018/hour while it runs and nothing while it is stopped.

## FAQ

### Why does WordPress redirect to localhost or to port 8080?

The `home` and `siteurl` options hold a different address than the one in your browser. Install with `--url` set to the hosted URL, or run `wp search-replace` from the old URL to the new one across all tables.

### Why does wp-admin loop or load without styles behind the URL?

WordPress doesn't know the request was HTTPS. The official image handles this with an `X-Forwarded-Proto` check in `wp-config.php`. Other setups need the same three lines, and Sandboxer's proxy always sends that header.

### Can I make the site public?

Yes: `sbx host $id 8080 --localhost --public`. Switching between private and public mints a new token for private links. Keep the WordPress admin password strong, because anyone can then reach `/wp-login.php`.

### Do I need the web installer?

No. `wp core install` does the same in about 2 seconds and lets you script the site URL, admin user and plugins. We didn't test the browser installer.

### Is `small` enough?

For a test site, yes. Both containers used about 220 MB of the 4 GB, and wp-admin pages answered in about a third of a second through the URL. Size up for heavy plugins or imports.

## Related

- [Run MySQL or MariaDB in a sandbox](/guides/run-mysql-or-mariadb-in-a-sandbox)
- [Run Docker Compose in a sandbox with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service)
- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Hosting](/docs/hosting)
