Learning goal. By the end of this tutorial you will have a compose.yaml file that boots a WordPress install in under a minute, connects cleanly to a MariaDB service that is actually ready before WordPress tries to reach it, uses Redis for the object cache, sends every outgoing email into a captured inbox you can inspect in your browser, lets you step through PHP with Xdebug, and exposes WP-CLI as a callable subcommand of docker compose. You will also understand why each of those pieces looks the way it does, so the next WordPress container project you touch does not feel like guesswork.
Assumed starting point
You are comfortable at a terminal, you have used Docker before for something other than a Hello World container, and you can read YAML without a reference card. You know what a PHP extension is. You do not need to have used Docker Compose v2 specifically, and you do not need to have set up Xdebug before. If you are coming from a Compose v1 background (docker-compose with the dash), most of the syntax will look familiar but a few things have moved.
I wrote the whole stack against Docker Engine 27 and Compose v2. Compose v1 reached end of life in June 2023, was removed from GitHub Actions runners in July 2024, and is no longer supported, per Docker's own deprecation notice. The command is now docker compose with a space. If you type docker-compose and it still works, that is a legacy binary on your machine and you should plan to stop using it.
Why Docker for WordPress development
Local WordPress development has three long-standing options. You can install PHP, MySQL, and a web server directly on your laptop. You can use a one-click app such as LocalWP, DevKinsta, or MAMP. Or you can run the whole stack in containers. The first option ages badly, especially if you work on sites with different PHP versions. The second option is great until you need something the app does not expose, at which point you end up reverse-engineering an opaque desktop GUI. The third option lets you describe the entire stack in a text file, commit it to git, share it with the rest of the team, and reproduce it on any machine that runs Docker.
The practical benefits that matter for a WordPress freelancer or agency: each project gets its own isolated PHP, MariaDB, Redis, and mail sink without polluting the host. A compose.yaml in the repo is self-documenting. A new developer can clone, run one command, and have a working site. You can pin PHP 8.1 for one client site and PHP 8.3 for another on the same machine. The same Compose file can be adapted into a CI job that spins up an integration environment to run PHPUnit or Playwright tests against a real WordPress install.
The trade-off is that you have to learn Compose properly, and a few WordPress-specific traps are easy to fall into. This article covers those traps directly.
A minimal Compose v2 setup (WordPress + MariaDB)
Start with the smallest thing that works: WordPress plus a database. Create a new directory, add a file called compose.yaml (the current convention; docker-compose.yml also still works), and write this:
services:
db:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_DATABASE: wordpress
MARIADB_USER: wordpress
MARIADB_PASSWORD: wordpress
MARIADB_ROOT_PASSWORD: rootpassword
volumes:
- db_data:/var/lib/mysql
wordpress:
image: wordpress:php8.3-apache
restart: unless-stopped
depends_on:
- db
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
volumes:
- wordpress_data:/var/www/html
volumes:
db_data:
wordpress_data:
Run docker compose up -d, wait a few seconds, and visit http://localhost:8080. WordPress will load the installer.
Read the Compose file carefully. The WORDPRESS_DB_HOST value is the service name db, not localhost or 127.0.0.1. Each service in a Compose file lives on a private user-defined bridge network that Compose creates automatically, and the DNS inside that network resolves service names to container IPs. localhost inside the WordPress container is the WordPress container itself, so localhost will never find MariaDB. This is the single most common bug when someone ports a plain docker run setup to Compose, and it is about to trip you up again later when Redis enters the picture.
The wordpress_data named volume holds /var/www/html for the whole WordPress install. I will explain the bind-mount alternative and when each makes sense in a moment.
Expected output of docker compose ps:
NAME IMAGE STATUS
myproject-db-1 mariadb:11 Up 20 seconds
myproject-wordpress-1 wordpress:php8.3-apache Up 18 seconds
What this minimal setup already gets wrong is what the next section is about.
Healthchecks and the depends_on / service_healthy gate
Reboot the stack with docker compose down && docker compose up -d a few times in a row. Sooner or later WordPress will greet you with "Error establishing a database connection" on the first load. This is not a network bug. It is a timing bug, and it happens because depends_on: - db only waits for the database container to be running, not for the database service inside to be ready to accept connections.
MariaDB and MySQL take ten to thirty seconds on a first boot to initialise the data directory, create the grant tables, and bind the listener. During that window the container is "up" from Docker's point of view but mysqld is not answering. WordPress starts, tries to connect, fails, and shows the error. If you then refresh the browser it usually works, because by the second request MariaDB is ready. That is a lousy developer experience, and in CI it becomes a flaky test.
The fix is to declare a healthcheck on the db service and tell WordPress to wait for service_healthy, not the default service_started. This is documented in the Compose startup order guide.
For MariaDB specifically, do not use mysqladmin ping. MariaDB's own blog, in their post on MariaDB Docker healthchecks without mysqladmin, warns that mysqladmin ping can return success during the /docker-entrypoint-initdb.d init phase before the server can actually take queries. The official image ships a helper script named healthcheck.sh that checks both connection and innodb readiness. Use that:
services:
db:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_DATABASE: wordpress
MARIADB_USER: wordpress
MARIADB_PASSWORD: wordpress
MARIADB_ROOT_PASSWORD: rootpassword
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
wordpress:
image: wordpress:php8.3-apache
restart: unless-stopped
depends_on:
db:
condition: service_healthy
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
volumes:
- wordpress_data:/var/www/html
start_period: 30s tells Docker to ignore failing checks for the first thirty seconds, so the first boot does not immediately flag the database as unhealthy. After that, a failed healthcheck five times in a row marks the container unhealthy and Compose will keep the WordPress container waiting. docker compose up -d now blocks until the gate is open.
Checkpoint. Run docker compose down -v to nuke the volumes, then docker compose up -d. WordPress should boot cleanly on the very first request, every time. If you ever see Error establishing a database connection after this change, the problem is no longer timing and you should look at network name resolution or credentials.
Adding Redis object cache (service-name networking)
WordPress benefits from an object cache even on a dev machine, because it exercises the same code path your production site uses. Add a Redis service:
redis:
image: redis:7-alpine
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
Then wire WordPress to it. There is no single Docker environment variable for "use Redis"; you inject the constants into wp-config.php by way of WORDPRESS_CONFIG_EXTRA, which the official image evaluates at container start. Extend the WordPress service:
wordpress:
# ...everything from before...
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DEBUG: "1"
WORDPRESS_CONFIG_EXTRA: |
define('WP_REDIS_HOST', 'redis');
define('WP_REDIS_PORT', 6379);
define('WP_CACHE', true);
The trap here is the same service-name trap that bit you with MariaDB, except nobody ever seems to remember it for Redis. WP_REDIS_HOST must be redis, the Compose service name, not localhost and not 127.0.0.1. localhost inside the WordPress container is the WordPress container. Redis is in a different container. I have debugged this bug in other people's stacks more often than I would like to admit, and the silent failure is worse than a crash because WordPress boots fine and quietly never uses the cache.
The constants alone do not turn on the cache. Install the Redis Object Cache plugin (Till Krüss) and activate the drop-in. Once the WP-CLI service from later in this article is in place you can do this in one line:
docker compose --profile cli run --rm wpcli wp plugin install redis-cache --activate
docker compose --profile cli run --rm wpcli wp redis enable
For the full flow of how the drop-in works, what object-cache.php is, and how to verify hit rate, see how to set up Redis object cache in WordPress. That article covers the verification piece I am deliberately keeping short here.
File permissions between host and container
WordPress in the official image runs as the www-data user, which is UID 33 on Debian. The wp-content/ directory is created with 1777 permissions and www-data ownership so Apache can write themes, plugins, and uploads.
When you use a pure named volume (wordpress_data:/var/www/html), Docker manages the filesystem and the UID mapping inside the volume does not collide with anything on your host. This is the easy mode and it is what the example in this tutorial uses for the WordPress tree.
When you add a bind mount that points at a directory in your project, for example ./wp-content/themes/mytheme:/var/www/html/wp-content/themes/mytheme, the files inside that directory are owned by your user on the host. Apache inside the container still wants to run as UID 33. On Linux you will typically see the theme files owned by some UID that does not exist inside the container, and Apache will still be able to read them because of world-read bits but will not be able to write. That is usually fine for a theme you are editing in your IDE, and fine-tunes nothing unless the theme tries to write inside its own directory.
The WordPress file permissions documentation recommends 644 for files, 755 for directories, and 600 for wp-config.php. For local development inside Docker those recommendations still hold, with one practical addition: never chmod -R 777 anything inside /var/www/html to "fix" a permissions problem. 777 is a smell, not a fix, and if you leave it in place your dev environment will diverge from production in ways that matter the day you ship. If something is broken, check ownership first.
A deeper dive on the full permissions model for a self-managed WordPress install lives in WordPress file permissions.
Environment variables and wp-config.php
The official WordPress image does two things with environment variables. The ones that start with WORDPRESS_ are mapped to a fixed set of wp-config.php constants by the container's entrypoint script. WORDPRESS_DB_HOST becomes DB_HOST, WORDPRESS_TABLE_PREFIX becomes $table_prefix, WORDPRESS_DEBUG when non-empty sets WP_DEBUG to true. The full list, with every supported key including _FILE variants for Docker Secrets, lives in the docker-library/docs WordPress content file on GitHub.
WORDPRESS_CONFIG_EXTRA is the escape hatch. Whatever you put there is dropped into wp-config.php verbatim, which is how you inject arbitrary constants the image does not understand natively:
WORDPRESS_CONFIG_EXTRA: |
define('WP_REDIS_HOST', 'redis');
define('WP_REDIS_PORT', 6379);
define('WP_CACHE', true);
define('DISABLE_WP_CRON', true);
define('WP_MEMORY_LIMIT', '512M');
The YAML pipe (|) preserves line breaks, which is what you want so each define() ends up on its own line in wp-config.php. The image's entrypoint uses PHP's eval() on this string at container start, so syntax errors will break WordPress without any sanity-checking at boot. If you introduce a typo and the container will not come up, docker compose logs wordpress usually shows the PHP parse error.
One word of warning: WORDPRESS_CONFIG_EXTRA only runs at first boot, when the entrypoint generates wp-config.php from wp-config-sample.php. If you change the value later and restart, nothing happens unless you also delete the existing wp-config.php inside the volume. For a dev stack the simplest recovery is docker compose down -v, which drops volumes, then up -d again. For real data you cannot throw away, edit wp-config.php directly with WP-CLI or a text editor.
Setting up Xdebug
The official wordpress:php8.3-apache image does not include Xdebug. You need a one-line custom Dockerfile that extends the base image and installs the extension:
# Dockerfile
FROM wordpress:php8.3-apache
RUN pecl install xdebug \
&& docker-php-ext-enable xdebug
Then change your Compose file's wordpress service from image: to build::
wordpress:
build: .
# ...rest as before...
environment:
# ...existing WORDPRESS_* vars...
XDEBUG_MODE: debug
XDEBUG_CONFIG: "client_host=host.docker.internal client_port=9003 start_with_request=trigger"
Two things here that older tutorials get wrong. First, Xdebug 3 (current since 2020) replaced the old Xdebug 2 settings like remote_host and remote_port with client_host and client_port. Any tutorial you find with remote_host is Xdebug 2 and will not work on a modern container. Second, host.docker.internal is the special hostname Docker Desktop provides on macOS and Windows that resolves to the host machine. On Linux it is not automatically available. If you are on Linux, add an extra_hosts entry, which is supported on Docker Engine 20.10 and newer:
wordpress:
# ...
extra_hosts:
- "host.docker.internal:host-gateway"
start_with_request=trigger (rather than yes) means Xdebug only activates when the request sets a specific trigger, typically via a browser extension such as Xdebug Helper. This matters because start_with_request=yes activates Xdebug on every request and makes PHP three to five times slower, which turns your dev site into a lazy slideshow. Trigger mode keeps PHP fast and only pays the debugging cost when you actually want to debug.
Configure your IDE to listen on port 9003 (the Xdebug 3 default; Xdebug 2 used 9000, another common confusion point) and set the path mapping from the container path /var/www/html to your project directory on the host.
Checkpoint. Rebuild with docker compose build wordpress && docker compose up -d. Inside the container, docker compose exec wordpress php -v should show a line mentioning with Xdebug v3.x.y. Drop a breakpoint in a theme file, fire the Xdebug trigger in your browser, and the IDE should stop on the line.
Email in development with Mailpit (not MailHog)
WordPress needs to send email for password resets, contact form submissions, WooCommerce order confirmations, and anything that hits wp_mail(). In production you connect a real SMTP service. In local development you do not want real email leaving your machine, and you do not want to guess whether the email was sent at all. The solution is a local SMTP catch-all that captures everything and gives you a web UI to inspect it.
Most tutorials you will find on this subject still recommend MailHog. MailHog has not been updated since 2020 and is effectively abandoned. The actively maintained drop-in replacement is Mailpit by Ralph Slooten. Mailpit uses the same default ports as MailHog (1025 for SMTP, 8025 for the web UI), ships a smaller image, adds full-text search, and handles TLS. For a fresh stack in 2026 there is no reason to reach for MailHog.
Add the service:
mailpit:
image: axllent/mailpit:latest
restart: unless-stopped
ports:
- "8025:8025"
environment:
MP_SMTP_AUTH_ALLOW_INSECURE: "true"
MP_MAX_MESSAGES: "500"
Only port 8025 is published to the host because that is the web UI. SMTP on 1025 stays inside the Compose network where WordPress can reach it as mailpit:1025.
Hooking WordPress up to Mailpit is where it gets a little awkward. The official WordPress image has no native SMTP configuration, so wp_mail() defaults to PHP's mail() function, which in a container with no MTA goes nowhere. You have two practical options. Install a plugin such as WP Mail SMTP and point it at host mailpit, port 1025, no encryption, no authentication. Or add a tiny must-use plugin that hooks the phpmailer_init action and sets the transport. The plugin approach is less friction for most teams and can be configured through the admin.
If you prefer the code path, create wp-content/mu-plugins/mailpit.php on your host (bind-mount it into the container) with:
<?php
// Route wp_mail through Mailpit in Docker.
add_action('phpmailer_init', function ($mailer) {
$mailer->isSMTP();
$mailer->Host = 'mailpit';
$mailer->Port = 1025;
$mailer->SMTPAuth = false;
$mailer->SMTPSecure = '';
});
Trigger any WordPress email flow (request a password reset, for example) and then open http://localhost:8025 in your browser. The message will be in the Mailpit inbox with full headers, HTML body, and any attachments.
For the broader picture of how WordPress delivers email and why local SMTP capture is part of a healthy dev workflow, see why WordPress is not sending email.
WP-CLI in a Docker container
The standard wordpress:php8.3-apache image does not include WP-CLI. WP-CLI lives in a separate official image, wordpress:cli, which is Alpine-based and contains only the CLI itself, not WordPress. To use it, you add a second service that shares the same volumes and environment variables as the WordPress service.
Here is where the most obscure trap in this whole tutorial lives. On Debian (which the wordpress:php8.3-apache image uses), the www-data user is UID 33. On Alpine (which the wordpress:cli image uses), www-data is UID 82. If you run wordpress:cli without overriding the user, it will touch files as UID 82, and those files will suddenly be inaccessible to UID 33 Apache. Symptoms range from silent permission errors to plugins failing to write files to uploads no longer being writable. The fix is to force WP-CLI to run as UID 33 explicitly:
wpcli:
image: wordpress:cli
depends_on:
wordpress:
condition: service_started
user: "33:33"
volumes:
- wordpress_data:/var/www/html
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
profiles:
- cli
The profiles: [cli] line keeps wpcli out of the default docker compose up start set. You only want it running on demand, not as a long-lived service. To invoke it:
docker compose --profile cli run --rm wpcli wp core version
docker compose --profile cli run --rm wpcli wp plugin list
docker compose --profile cli run --rm wpcli wp user create dev dev@localhost --role=administrator --user_pass=devdev
Expected output of the first command:
6.7.1
If you forget user: "33:33", you will not see an error immediately, but the first time WP-CLI creates a file (plugin install, wp config create, upload rewrite) your WordPress container will start acting strange. I have lost whole evenings to this one. Set the user and move on.
When you move the same stack pattern from local dev to a self-managed server, the full-page cache in front of PHP-FPM is the next lever. See WordPress + Nginx FastCGI cache for the production-side companion to this article.
macOS volume performance (named volumes vs bind mounts)
On Linux, Docker runs on the host kernel and bind mounts have near-native performance. On macOS, Docker Desktop runs inside a virtual machine using the Apple Virtualization Framework, and every file access through a bind mount crosses the VM boundary. With VirtioFS, which is the current Docker Desktop default on macOS as of version 4.x, bind mounts are roughly three times slower than named volumes for a typical WordPress workload, according to the Docker Desktop performance guide for Mac and the 2025 Docker storage volume comparison. Before VirtioFS the gap was five to six times. It is better now. It is still not free.
For a WordPress dev stack on a Mac, the pattern that performs well without sacrificing editability is a named volume for the full WordPress install combined with selective bind mounts only for the directories you actively edit:
wordpress:
build: .
# ...
volumes:
- wordpress_data:/var/www/html
- ./wp-content/themes/mytheme:/var/www/html/wp-content/themes/mytheme
- ./wp-content/plugins/myplugin:/var/www/html/wp-content/plugins/myplugin
This gives you fast database-adjacent access for the thousands of WordPress core and plugin files you never touch, and host filesystem speed for the one theme and one plugin you are working on. Bind-mounting the entire wp-content/ directory on macOS is tempting and also the single fastest way to make a WooCommerce dev site feel like it is running on a 2010 netbook. Do not do it. The heavy plugin trees (WooCommerce, Elementor, page builders) generate enough file traffic to saturate VirtioFS.
If you want to sidestep the whole Docker Desktop performance story on macOS, OrbStack and Lima are both well regarded alternatives in 2026 and both handle bind mounts noticeably better than Docker Desktop. Either is worth an evening of evaluation if bind mount performance is your bottleneck.
On Linux, none of this applies. Bind mounts are fine. Ignore this section.
Docker vs LocalWP vs DevKinsta for local development
LocalWP (from WP Engine) and DevKinsta (from Kinsta) are desktop apps that give you one-click WordPress environments without touching the terminal. They have their place. They are also not the same tool as Docker Compose, and it is worth being honest about where each one wins.
| Criterion | Docker Compose | LocalWP | DevKinsta |
|---|---|---|---|
| Reproducibility across machines | Excellent (compose.yaml in git) |
Weak (local DB dumps) | Weak |
| PHP version per project | Yes, any version | Limited set | Limited set |
| Works in CI | Yes, same file | No | No |
| Team sharing | Git commit | Export/import site files | Export/import site files |
| Learning curve | Medium | Low | Low |
| Customization | Anything you can run in a container | What the GUI exposes | What the GUI exposes |
| WP-CLI access | Yes, once wired up | Yes, built in | Yes, built in |
| Performance on macOS | Tune with named volumes | Fast | Fast |
| Managed hosting parity | You build it | Matches WP Engine | Matches Kinsta |
Pick Docker Compose when you need PHP version parity with a production server, when more than one developer touches the codebase, when the project will grow into CI integration tests, when you already work with containers for other parts of your stack, or when you want the setup committed in git. This is the agency and developer default.
Pick LocalWP or DevKinsta when you are the only person working on the site, when you want zero setup, when you do not need to match a specific server environment, or when you are a designer who needs a WordPress playground rather than a dev environment. Both are excellent at what they do. Neither replaces Compose for a team shipping code to a non-trivial production stack.
The honest weakness of Docker Compose for WordPress is that the first-run setup takes a couple of hours of reading and debugging. This article is most of that reading. The honest weakness of LocalWP and DevKinsta is that the moment you need something the GUI does not expose, the only answer is to export your site and rebuild it somewhere else.
What you learned
- A Compose v2 file describes a full WordPress dev stack in version control.
docker-compose(v1) is gone; the current command isdocker compose. depends_onwithoutcondition: service_healthyis a loaded gun for WordPress + MariaDB boots. Always declare a proper healthcheck and gate on it.- Redis in Compose is reachable as
redis, notlocalhost. The same rule applies to every service in your network. WORDPRESS_CONFIG_EXTRAis how you inject arbitrarydefine()calls intowp-config.php, and it only runs at first boot.- Xdebug 3 is the current generation, lives in a custom Dockerfile, and you want
start_with_request=triggerunless you enjoy your dev site running at a quarter speed. - Mailpit replaces the abandoned MailHog as the local SMTP catch-all, with identical ports.
- WP-CLI lives in a separate
wordpress:cliimage, requiresuser: "33:33"to match Debian UID expectations, and belongs behind a Compose profile so it does not run by default. - On macOS, a named volume for the WordPress tree plus selective bind mounts for the directories you edit is the balance between speed and developer ergonomics. On Linux this is a non-issue.
Complete final compose.yaml
services:
db:
image: mariadb:11
restart: unless-stopped
environment:
MARIADB_DATABASE: wordpress
MARIADB_USER: wordpress
MARIADB_PASSWORD: wordpress
MARIADB_ROOT_PASSWORD: rootpassword
volumes:
- db_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
redis:
image: redis:7-alpine
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
mailpit:
image: axllent/mailpit:latest
restart: unless-stopped
ports:
- "8025:8025"
environment:
MP_SMTP_AUTH_ALLOW_INSECURE: "true"
MP_MAX_MESSAGES: "500"
wordpress:
build: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
ports:
- "8080:80"
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DEBUG: "1"
WORDPRESS_CONFIG_EXTRA: |
define('WP_REDIS_HOST', 'redis');
define('WP_REDIS_PORT', 6379);
define('WP_CACHE', true);
XDEBUG_MODE: debug
XDEBUG_CONFIG: "client_host=host.docker.internal client_port=9003 start_with_request=trigger"
# Uncomment on Linux if host.docker.internal is not resolvable:
# extra_hosts:
# - "host.docker.internal:host-gateway"
volumes:
- wordpress_data:/var/www/html
- ./wp-content/themes/mytheme:/var/www/html/wp-content/themes/mytheme
- ./wp-content/plugins/myplugin:/var/www/html/wp-content/plugins/myplugin
wpcli:
image: wordpress:cli
depends_on:
wordpress:
condition: service_started
user: "33:33"
volumes:
- wordpress_data:/var/www/html
environment:
WORDPRESS_DB_HOST: db
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
WORDPRESS_DB_NAME: wordpress
profiles:
- cli
volumes:
db_data:
wordpress_data:
And the accompanying Dockerfile in the same directory:
FROM wordpress:php8.3-apache
RUN pecl install xdebug \
&& docker-php-ext-enable xdebug
Bring it up with docker compose up -d, shell in with docker compose exec wordpress bash, and run WP-CLI with docker compose --profile cli run --rm wpcli wp <command>. From here, the stack is yours. When you are ready to move beyond single-host Docker Compose and into production Kubernetes, see Migrating from Docker Compose to Kubernetes.