
For a long time we’ve wanted to make it possible to use Shopware CLI with installations that aren’t running locally. With the new ssh environment type, you can.
To use this new feature, simply define an ssh environment in .shopware-project.yml, select it with -e, and use the same commands as before. project dump, project sql, project console, and project logs all run against the selected remote environment.
Running commands over SSH is the simple part. The more interesting problems are everything around it: keeping repeated connections fast, accessing databases that are only reachable from the remote network, and resolving credentials without hardcoding them.
This post covers those three problems and what the new ssh environment enables.
Choosing an environment
Every project command talks to an environment, selected with -e. Each environment type - local, Docker, ssh - answers the same small set of questions for the command: how to run a console command, where the database is, where the logs live. Because all of that sits behind one abstraction, the commands never care where your shop actually runs. A remote host is just another place to point them at.
Declaring it is a handful of lines in .shopware-project.yml:
- environments:
- local:
- type: docker
- url: http://localhost:8000
- prod:
- type: ssh
- url: https://www.example.com
- ssh:
- host: example.com
- user: deploy
- directory: /var/www/shop
- # optional, defaults to 22 / ssh agent / "php"
- port: 22
- identity_file: ~/.ssh/id_ed25519
- php_binary: /usr/bin/php8.3
directory is the absolute path of the project root on the remote host. If the host already has an entry in ~/.ssh/config, you can leave out user and identity_file.
Problem one: connecting is not free
Every command is a one-shot ssh call. That is simple and stateless, but a naive implementation pays the TCP and authentication handshake on every single invocation. On a shop where a dump is followed by a console command and then a log tail, you feel that.
So the ssh environment turns on connection multiplexing: ControlMaster=auto with a stable ControlPath and ControlPersist=10m. The first call opens a master connection, and every following call reuses it. The handshake happens once; the rest is basically free.
Two small things bit us here, and both are the kind of detail that is invisible until it breaks on someone else's machine:
sshappends a random suffix while binding the control socket, andsun_pathis limited to 104 bytes. On macOS the temp directory path alone is already long, so our first socket name pushed it over the limit and every command failed with exit 255. The fix was a short, stable, hashed name.Newer OpenSSH clients print a post-quantum key exchange warning to stderr on every call.
LogLevel=ERRORkeeps that noise out of the output while still reporting real connection failures.
Neither is conceptually hard. Both are exactly the kind of thing you do not want to rediscover in every project's deploy script.
Problem two: the database is on the other side
The database is what made us stop and think. A production shop's MySQL is usually not reachable from the outside. It lives on the remote network, or the shop connects to it through a unix socket that only exists on that host.
We did not want a long-running ssh -L tunnel that some other command has to set up, keep alive and clean up. Instead, every MySQL connection is dialed through ssh -W on the multiplexed connection. The Go MySQL driver lets you register a custom dialer network, so we register one that forwards each new connection over ssh. The tunnel process lives exactly as long as the database connection, and there is nothing to leak when you press Ctrl-C.
Two details were less obvious than expected.
The localhost case. When DATABASE_URL points at localhost, PHP does not open a TCP connection at all, it uses a unix socket. Forwarding 127.0.0.1:3306 would have been quietly wrong. We ask PHP on the remote host for pdo_mysql.default_socket and forward that socket path instead - OpenSSH can forward both a host:port and a socket.
The credentials. We did not want to parse .env files and hope DATABASE_URL is a literal string, because it often isn't. We run vendor/bin/shopware-deployment-helper dump-env on the remote host, which evaluates the full Symfony dotenv cascade - including .env.local.php and variable interpolation - and prints the resolved values as JSON. One call, and the credentials are exactly what the remote shop would use.
What you can do with it now
Here is the payoff. To get the production database onto a local machine with customer data anonymized before it ever leaves the server:
- shopware-cli project dump -e prod --anonymize --output prod.sql
And to load it into a local environment, without looking up a single credential:
- shopware-cli project sql --file prod.sql
That second command is the part we use the most. project sql resolves the connection details of whatever -e points at, so the same command imports into a local PHP setup or a Docker compose database. There is an interactive SQL shell and one-off queries too:
- shopware-cli project sql -e prod "SELECT COUNT(*) FROM \`order\`"
If you only need a slice of production, --limit keeps the newest N rows of a table and filters everything that references it, so the dump stays importable with foreign keys enabled:
- shopware-cli project dump -e prod --limit order=100 --output small.sql
And because everything goes through the same environment, the rest of the project commands follow:
- shopware-cli project console -e prod cache:clear
- shopware-cli project logs -e prod --follow
- shopware-cli project clear-cache -e prod
What it costs you
Remote database access has one hard requirement: the project needs shopware/deployment-helper, because that is what resolves the environment on the remote side.
- composer require shopware/deployment-helper
shopware-cli checks for it before dumping and fails with a clear message if it is missing, rather than guessing at credentials. For older shops there is a version gate: project create only adds the helper on Shopware 6.5.8 and newer, because the helper requires symfony/yaml ^6.0 || ^7.0 and that conflicts with the symfony/yaml ~5.4 pinned by 6.4. On 6.4 you are better off importing the dump with the mysql client than relying on the tunnel.
There is also --limit, which freezes the rows it wants to keep into temporary staging tables on the remote database, so the database user needs CREATE and DROP. And for very large dumps, project sql reads its input into memory, so a compressed dump is best piped through a decompressor:
- gunzip -c prod.sql.gz | shopware-cli project sql
None of these are blockers, but they are the honest boundaries of the current implementation.
Conclusion
Pointing a CLI at a remote Shopware project looks like it should be one ssh call. It is not: the connection has to be cheap, the database has to be reachable from a place it was never meant to be reached from, and the credentials have to come from the environment that actually knows them. Each piece is small. Together they are exactly the kind of fiddly, easy-to-get-wrong work you do not want to reimplement in every project.
That is why we like the abstraction: the ssh support did not add a single new command, it just made an existing one answer the same questions for a remote host. project dump -e prod followed by project sql < prod.sql is a two-liner that replaces a surprising amount of shell glue.
Give it a try on your next staging or production checkout, and let us know how it works for your setup.




