Shared local development for WordPress projects. A project opts in with one
small file and gets a trusted-HTTPS site — no per-project proxy, certificate,
database, port, or /etc/hosts work.
*.example.dev ──► 127.0.0.2 ──► Traefik ──► your site container
│
one MariaDB ──┤ (one database per site)
one Mailpit ──┘ (one inbox for all of them)
Every project shares one proxy, one database server, one inbox, and one certificate. Adding a project costs a config file and a schema — not another MySQL container, another core download, and another port to keep out of everything else's way.
This suits sites. Plugin and theme repos are usually better served by wp-env, which does multi-version testing and PHPUnit scaffolding that this deliberately doesn't.
- Docker. Docker Desktop, OrbStack, or Colima.
- mkcert, with its root
CA trusted (
mkcert -install). That touches your system trust store and needs a password, so it can't be done for you. - Node 20+.
- macOS or Linux. Windows/WSL2 isn't supported: Docker Desktop publishes ports on the Windows side, where binding a loopback alias isn't dependable.
This is the one decision to make up front, and the only part that isn't automatic. Your site's hostname has to resolve to the proxy. There are three ways, and they trade off setup against convenience.
Point a wildcard A record at the loopback alias:
*.example.dev. A 127.0.0.2
Every project on that domain then works with no further DNS work, ever. The certificate is a wildcard, so adding a project needs no certificate work either.
The tradeoffs: you need a domain, and resolving a public record needs a network
connection — nothing resolves on a plane (see Working offline).
Use a domain you don't also serve from, or a subdomain of one — *.dev.example.com
works fine and leaves example.com alone.
*.localhost resolves to 127.0.0.1 on macOS and on systemd Linux with no DNS
record, no network, and no /etc/hosts line. Set a host like
mysite.dev.localhost and it just works.
This needs the proxy on 127.0.0.1 rather than the default alias:
{ "bindAddress": "127.0.0.1" }in ~/.happy-env/config.json. The cost is that the proxy then contends for port
80 with everything else on your machine, which the alias exists to avoid.
Use three labels (mysite.dev.localhost, not mysite.localhost) if you want a
wildcard certificate covering siblings — see Certificates.
Universal, offline, needs no domain, and is manual for every host:
127.0.0.2 mysite.example.dev
npm install --save-dev @happyprime/env{
"scripts": {
"env:start": "happy-env start",
"env:stop": "happy-env stop",
"env:cli": "happy-env cli"
}
}On macOS, unless you chose 127.0.0.1 above, persist the loopback alias the
proxy binds — once per machine:
sudo cp node_modules/@happyprime/env/com.happyprime.loopback-alias.plist /Library/LaunchDaemons/
sudo launchctl load -w /Library/LaunchDaemons/com.happyprime.loopback-alias.plistLinux needs nothing here: the whole 127.0.0.0/8 routes to loopback already.
Then, in any project:
npm run env:startThe first run generates the certificate and starts the shared services, so
there's no separate setup step. start brings up the shared services if they
aren't already running, creates the site's database, boots it, and installs
WordPress on first run. It finishes by printing the site's URL and the inbox
every project's mail lands in.
A repo is a wp-content directory, so almost everything is read off disk. Most
projects need one line:
{
"host": "mysite.example.dev"
}Themes, plugins, and mu-plugins are discovered from ./themes, ./plugins, and
./mu-plugins. Symlinks are resolved to their real paths, so shared code linked
out to a sibling repo is mounted where it actually lives — a symlink to an
absolute host path means nothing inside a container.
Use sites instead of host. Each site gets its own container and database; the
key names both. root points at a site's wp-content when it isn't the repo
root:
{
"php": "8.3",
"wordpress": "7.1.3",
"config": { "WP_MEMORY_LIMIT": "512M" },
"sites": {
"blog": {
"host": "blog.example.dev",
"root": "./blog/wp-content"
},
"shop": {
"host": "shop.example.dev",
"root": "./shop/wp-content"
}
}
}A WordPress network can serve more than one domain from a single database. That
is one site as far as this is concerned — one container, one database — but the
proxy has to answer for every domain in it, so name the rest as aliases:
{
"host": "bedsbirdclub.example.dev",
"aliases": [ "bnhs.example.dev" ],
"config": {
"MULTISITE": true,
"SUBDOMAIN_INSTALL": true,
"DOMAIN_CURRENT_SITE": "bedsbirdclub.example.dev",
"PATH_CURRENT_SITE": "/",
"SITE_ID_CURRENT_SITE": 1
}
}host stays canonical: it is the URL WordPress is configured with, the one
printed on start, and the one the inbox domain is derived from. An alias only
has to route — which site answers on it is the network's business, decided from
wp_blogs, not the proxy's. Aliases join the certificate the same way hosts do,
each contributing its own parent, so a domain a level deeper brings its own
wildcard.
This is for one network across several domains. Separate sites that happen to
share a repo want sites instead, so each gets its own database.
Note that WP_HOME and WP_SITEURL are set from host (unless the site sets
core), and core removes their filters under multisite — "WP_HOME and
WP_SITEURL should not have any effect in MS", in ms-default-filters.php — so a
network reads its URLs from the database and the constants sit there harmlessly.
For a network that sets core, the multisite constants above usually belong to
the checkout's own wp-config.php, not to config; see below.
| Key | Default | Notes |
|---|---|---|
host |
— | Single-site shorthand. Mutually exclusive with sites. |
sites |
— | Several sites, keyed by slug. The slug names the database. |
aliases |
[] |
More hostnames routed to the same site. See above. |
php |
8.4 |
8.5 works. 8.6 isn't released. |
wordpress |
7.1 |
7.1 resolves to its latest patch at creation; 7.1.3 pins. |
themes |
every dir in ./themes |
Default themes are skipped — core supplies them. |
plugins |
every dir in ./plugins |
See below. |
muPlugins |
./mu-plugins |
Mounted entry by entry, not as one directory. |
mounts |
{} |
Extra paths, as { source: target }. Targets are relative to wp-content; one starting with / is absolute in the container. |
config |
{} |
Extra wp-config.php constants. |
uploads |
{} |
{ "fallback": "https://prod" } serves missing media from production. |
root |
repo root | Per-site only. |
core |
— | A full WordPress checkout to run instead of the image's core. See below. |
altis |
— | true for an Altis Local Server repo. Mutually exclusive with host and sites. See below. |
Declared vs. discovered plugins. Listing plugins explicitly is an intent to
use them, so they're installed and activated. Anything merely found on disk is
mounted and left alone — whether it's active is the database's business, and
force-activating would silently override a site's real state. Declared entries
accept a wordpress.org slug (woocommerce), a pinned slug (woocommerce@10.7.0),
a zip URL, or a local path (./plugins/thing, mounted so it stays editable).
A pin is applied when the plugin is first installed. Changing @10.7.0 to
@10.8.0 won't move a site that already has it — the plugin is present, so
there's nothing to install. happy-env cli <site> plugin update moves it, and
reset (or destroy) starts over.
Extra mounts. themes, plugins, mu-plugins, and uploads cover most
repos. mounts is for what's left — in practice a composer vendor directory
sitting at the wp-content root, which plugins in the same repo autoload from
rather than from their own directory:
{
"host": "mysite.example.dev",
"mounts": { "./vendor": "vendor" }
}Sources are relative to the site root, targets to wp-content. A source that
doesn't exist is an error at start rather than a puzzle later — a missing
vendor says to run composer install.
A target that starts with / is a path anywhere in the container. That's the
way in for an Apache or PHP config file, since neither has a home under
wp-content. Both kinds can sit side by side:
{
"host": "mysite.example.dev",
"mounts": {
"./vendor": "vendor",
"./local/site.conf": "/etc/apache2/conf-enabled/site.conf",
"./local/php.ini": "/usr/local/etc/php/conf.d/local.ini"
}
}Apache reads every *.conf in conf-enabled and PHP every *.ini in
conf.d, so a new file name adds to the image's config. Reuse a name the image
already has only to replace that file outright.
Some repos track all of WordPress, not just wp-content, and the point of
running one locally is to run that core. core names the checkout:
{
"host": "mysite.example.dev",
"core": "../mysite",
"mounts": {
"./local/env.php": "../env.php"
}
}The checkout is bind-mounted whole as the docroot, so it supplies core,
wp-content, and its own wp-config.php. Nothing is discovered or installed:
themes, plugins, muPlugins, and root are errors beside core, because
the checkout already answers all of them. The wordpress key still picks the
image tag, and so must be one that exists — but only its PHP and Apache are
used; the WordPress inside is ignored.
Nothing is written into the checkout. Docker creates a missing mountpoint
as an empty directory on the host, which with a bind-mounted docroot means
inside the repo. So a mounts entry into the checkout may only replace a file
or directory that is already there, and start refuses one that isn't. This is
what makes the shape above work: the repo's wp-config.php includes a
gitignored env.php, the checkout has one (its contents don't matter), and the
mount swaps in the one that names hp-mysql. If the file isn't there yet,
create it — it's gitignored — so there's something to mount over.
Because the image's wp-config.php never runs and no mu-plugin can be added,
three things arrive through PHP's auto_prepend_file instead, from
services/php/prepend.php: $_SERVER['HTTPS'] is set from
X-Forwarded-Proto, config constants are defined, and mail is pointed at
Mailpit by seeding $wp_filter before plugin.php loads. config runs
before wp-config.php, so its constants win; a wp-config.php that then
defines the same constant unconditionally logs a PHP warning. Prefer a swapped
env file for anything the checkout's own config also sets.
A checkout whose .htaccess sets php_value auto_prepend_file, as Wordfence's
firewall does, replaces this prepend for web requests. That path is usually a
production one, so every request fails with a PHP error naming it. If the path
does exist in the container, the prepend silently doesn't run, and mail is not
caught. Either way, mount a local .htaccess without that line over the
checkout's.
WP_HOME and WP_SITEURL are not defined for these sites. The checkout's
config and database own its URLs, so point them at host and any aliases —
for a multisite that's wp_site, wp_blogs, and wp_sitemeta.
A first start still runs wp core install if the database is empty. A repo
like this usually comes with a database rather than a fresh install — and for a
multisite config core install can't succeed anyway — so import the dump into
the site's database (127.0.0.1:3307, root / password, named after the
slug) before the first start. For the same reason reset refuses a core
site, since it would drop that import. --force resets it anyway.
A repo carries no uploads, so a site troubleshot against real content would need
every image synced down first. uploads.fallback skips that: any file missing
from the local uploads directory is served from a production origin, and files
you do have on disk still win.
{
"host": "mysite.example.dev",
"uploads": { "fallback": "https://mysite.com" }
}A request for /wp-content/uploads/2024/06/hero.jpg is served from disk if it's
there and reverse-proxied from https://mysite.com/wp-content/uploads/2024/06/hero.jpg
if it isn't — so the media appears to come from the dev site, and nothing in the
page markup points at production. Set it per site under sites, or once at the
repo root for every site to inherit.
This is the container-side equivalent of a Valet driver's missing-file fallback.
Valet routes every request through PHP, so a driver can catch a missing upload;
here Apache serves uploads straight off disk and a missing file never reaches
PHP, so the fallback is Apache config instead — generated, mounted read-only,
and never touching the official image. It's built from mod_proxy + mod_ssl,
which that image doesn't enable by default; the generated config loads them, so
there's nothing to install.
fallback is for a publicly reachable origin — production, a staging host, a
CDN. It resolves over normal DNS from inside the container and its certificate is
validated against the image's CA bundle, both of which a real host satisfies.
Pointing it at another local *.dev site won't work: that name resolves to the
host loopback the container can't reach, and its certificate is the local CA the
container doesn't trust.
Altis Local Server runs its own Traefik on :80/:443 of every interface, so
under OrbStack it can't run beside the shared proxy (see
Sharing ports). Its containers already carry Traefik labels,
so the shared proxy routes them instead:
{
"altis": true
}start runs the containers Local Server generated in vendor/docker-compose.yml,
connects the shared proxy to Local Server's proxy network, and adds the
project's hosts to the certificate. stop and status work as usual. The hosts
come from name, tld, and domains under extra.altis.modules.local-server
in composer.json, with Local Server's defaults: the repo directory's name on
altis.dev. Nothing about the project goes in .happy-env.json.
Local Server keeps everything else. Use composer server cli, composer server db, and the rest; happy-env cli, reset, and destroy refuse. Plain
composer server start still works once the shared proxy is stopped, so
teammates who don't run happy-env are unaffected.
First run. Local Server writes its compose file and creates its network in
composer server start, so run that once. With the shared proxy up, it then
stops at "Could not start traefik proxy". That's expected. On a fresh install,
run happy-env services stop first so Local Server can finish installing, then
composer server stop --clean to give back the ports.
DNS. *.altis.dev is public DNS pointing at 127.0.0.1, not the loopback
alias. start checks each host and prints the /etc/hosts line a browser
needs:
127.0.0.2 example.altis.dev s3-example.altis.dev
With bindAddress set to 127.0.0.1, no line is needed.
How it routes. Two settings in traefik.yml match Local Server's own proxy:
TLS on every websecure router, because its S3, uploads, and Tachyon routers
don't ask for it; and insecureSkipVerify, because its nginx serves HTTPS with
a self-signed certificate. The proxy joins the proxy network under the alias
proxy, which is the name Local Server's PHP container links the project's
hosts to. That's how S3 Uploads and Tachyon requests from PHP get routed.
What bites. Recreating the shared proxy disconnects it from that network.
If the site starts returning 404 after happy-env services stop or a services
update, run start in the Altis repo again. Subdomain multisite hosts don't
route: the shared proxy's Traefik rejects Local Server's *.example.altis.dev
rules and serves only the hosts named outright.
~/.happy-env/config.json holds the few things that are about your computer
rather than any project. It's optional, and most people never write one.
| Key | Default | Notes |
|---|---|---|
bindAddress |
127.0.0.2 |
Where the proxy listens. 127.0.0.1 for .localhost. |
dashboardHost |
proxy.localhost |
Traefik's dashboard. Only reachable if it resolves to bindAddress. |
mailHost |
derived | The inbox's host. Defaults to mail. plus your project's parent domain. |
| Command | Does |
|---|---|
happy-env start [site…] |
Start the shared services and this repo's sites |
happy-env stop [site…] |
Stop containers, keeping them (restart is seconds) |
happy-env reset [site…] |
Drop the database and reinstall WordPress fresh (asks first; --yes skips; a core site needs --force) |
happy-env destroy [site…] |
Remove containers, volumes, and the database |
happy-env prune [--yes] |
Remove what old sites and versions left behind (asks first; see below) |
happy-env status |
What's running |
happy-env cli <site> … |
Run wp-cli, e.g. happy-env cli blog plugin list |
happy-env cert [--only] |
Reissue the certificate (start does this for you) |
happy-env services stop |
Stop the shared proxy, MariaDB, and Mailpit |
Admin credentials are admin / password.
Core comes from the image. Sites run the official
wordpress:<wp>-php<php>-apache image, which already contains WordPress — so
choosing a version is a tag lookup and a cold boot is seconds. Nothing is
downloaded or unpacked per project.
That's also the whole version story: anything is fair game if the tag exists.
"wordpress": "beta" follows the next release's latest beta or RC, and
"beta-7.1.1-RC1" pins one exactly. A PHP version works the day its image is
published and not before.
WordPress nightly/trunk has no image at all. To run trunk, use core with
a clone of github.com/WordPress/WordPress, which mirrors the code nightly
builds are made from:
mkdir trunk && cd trunk
git clone --depth 1 https://github.com/WordPress/WordPress.git
cp WordPress/wp-config-sample.php WordPress/wp-config.php{
"host": "trunk.example.dev",
"core": "./WordPress"
}In wp-config.php, set DB_NAME to trunk (the slug), DB_USER to root,
DB_PASSWORD to password, and DB_HOST to hp-mysql. The first start
installs WordPress, and git pull in the clone moves it forward. Its
wp-content is the clone's: put a plugin under test in
WordPress/wp-content/plugins, or create an empty folder there and mount the
plugin over it.
Security updates arrive by re-pull. Images are rebuilt under the same tag
for each security release, but Docker never re-pulls a tag it already has. So
start pulls a site's images, and the shared services', when the last
successful pull is a day old, and recreates whatever changed. Offline, it says
so and starts with what's there. MariaDB follows lts, and upgrades its data
in place when that moves to a new release, after backing up its system tables
into the data volume.
Only tags for current WordPress releases are rebuilt: once 7.2
ships, 7.1 stops getting PHP fixes, and so does a pinned 7.1.3 once 7.1.4
does. Pinning WordPress pins PHP too. Check a running site with
docker exec hp-site-<slug> php -v.
A site with core set still takes PHP and Apache from the image, and the daily
pull, but not its WordPress: the checkout is the docroot and the image's core is
never copied anywhere. Everything below about volumes is about sites without it.
Core lives in a volume named for the WordPress version, so switching versions picks up the right core. The image's entrypoint copies core in only when the volume has none, and never looks at the version again either way — so a single volume would go on serving whatever booted into it first, no matter what the tag and the config now say. That's the worst way to fail when the reason you pinned a version was to test against it. Databases and uploads live outside that volume, so switching back and forth costs nothing.
The volume is keyed on the version as you wrote it, which has one consequence
worth knowing: "wordpress": "7.1" is a single volume even though the tag behind
it moves with every patch. A site created on 7.1.3 stays on 7.1.3 — destroy and
start again is what picks up 7.1.4. Pin fully ("7.1.3") if you want the version
to be unambiguous on sight.
Leftovers pile up. Each version a site has run keeps its core volume and
image, and a stopped site keeps its container. destroy clears one site, but
only from inside its repo. happy-env prune works from anywhere, across the
whole machine. It lists, then asks before removing:
- site containers stopped more than 30 days;
- core volumes no remaining container uses;
wordpresssite and wp-cli images no remaining container needs, including untagged ones a re-pull left behind.
Databases, uploads, and the shared services are never touched. Nothing is
forced: if Docker refuses to remove something, prune reports it and moves on.
A pruned site's next start recreates its container and core volume, and
installs its declared plugins again. Anything installed by hand with cli,
such as an undeclared plugin or a language pack, lived in the core volume and
is gone.
One MariaDB. Every site is a schema on the shared server, not a container of
its own. Adding a site costs a database, and a test database costs the same.
It's published on 127.0.0.1:3307 for host tools, since a Homebrew MariaDB
usually holds 3306. Sites reach it as hp-mysql over the network.
Routing is by label, not port. Site containers join the hp-env network and
Traefik routes on the Host header, so nothing publishes a host port. There are no
ports to allocate and none to collide — which is worth stating plainly, because
port collisions are the most common way an e2e suite fails, silently running
against whatever WordPress answered instead.
Mail is caught, never sent. One Mailpit holds every project's mail, the same
way one MariaDB holds every project's database. Sites reach its SMTP listener as
hp-mailpit on the shared network, and an mu-plugin mounted into every site
points PHPMailer at it — hooked last, so a site configuring its own SMTP
transport still has its mail caught rather than delivered from your laptop.
The inbox is at https://mail.<your domain> — derived from the domain your
project already uses, so the wildcard DNS that resolves your sites resolves it
too and the certificate already covers it. A repo on foo.example.dev puts the
inbox on mail.example.dev. Set mailHost if your projects don't share a
domain, or if you'd rather name it yourself.
Nothing is published on the host. The usual 1025 and 8025 stay free, so a Homebrew Mailpit or MailHog can go on running beside this one.
Site config is eval'd, not written. The official image's wp-config.php runs
if ($configExtra = getenv_docker('WORDPRESS_CONFIG_EXTRA', '')) { eval($configExtra); } just before it requires wp-settings.php, so WP_HOME,
WP_SITEURL, and anything in config are applied as part of wp-config — and
because core hasn't loaded yet, early enough that it derives WP_CONTENT_URL
and WP_PLUGIN_URL from the right URL. No mu-plugin is involved. The image also
sets $_SERVER['HTTPS'] from X-Forwarded-Proto on its own, so terminating TLS
at Traefik needs no handling at all.
One certificate serves every project on the machine. It lives in
~/.happy-env/certs rather than in the package: npm may replace node_modules
wholesale on any reinstall, and a copy per project would be worse than useless.
Its names are derived from the hosts your projects declare — each host's own
parent, deduplicated — and recorded in ~/.happy-env/certs/coverage.json.
start reissues only when a project brings a name that isn't covered yet, and
restarts the proxy when it does, since Traefik reads the certificate once at
startup.
Names accumulate across projects. A machine running one project on
*.example.dev and another on *.other.dev has one certificate carrying both;
dropping a name the moment a project stopped using it would mean reissuing every
time you switched projects. A stale name costs nothing. happy-env cert --only
reissues from just the current project when you want it gone.
Where a wildcard is used, and where it isn't. Hostname verification refuses a
wildcard that spans a registry-controlled domain: *.dev and *.com are
rejected, and an unknown TLD counts as a registry too — which is why the *.test
scheme this replaced could never work. So:
| Host | Certificate name |
|---|---|
mysite.example.dev |
*.example.dev |
www.foo.example.dev |
*.foo.example.dev |
mysite.dev.localhost |
*.dev.localhost |
mysite.localhost |
mysite.localhost |
example.dev |
example.dev |
The last two get no wildcard — the name above them is a registry — so each such host is named outright and a sibling means reissuing. That's the reason to prefer three labels.
Wildcards are also only one label deep: *.example.dev does not cover
www.foo.example.dev. Deriving each host's own parent is what handles that, so
nesting needs no thought.
Only one proxy can hold :80/:443 at a time under OrbStack. start checks
first and names whatever is holding the ports, rather than letting Docker report
an address and leave you guessing.
This is worth explaining, because the obvious fix doesn't work. Traefik binds a
loopback alias precisely so it could sit beside something on 127.0.0.1. Under
Docker that does work. Under OrbStack it doesn't: ports are allocated by
number and routed to a single container regardless of which host IP the binding
names. Two proxies on 127.0.0.1 and 127.0.0.2 both start happily, and then
one of them silently answers for both addresses — verified by watching one
stack's proxy serve another's certificate while its own sat there healthy and
unreachable. Picking a third loopback address changes nothing.
It doesn't help that some stacks publish '80:80' with no host IP at all, which
is every interface — so they conflict with anything on those ports no matter
what.
It doesn't have to be a container. Laravel Valet, a Homebrew nginx, an Apache
left running from something else — anything holding :80/:443 on the host does
this too, and that case is the more confusing one, because nothing reports it.
compose up succeeds, Traefik starts and logs nothing wrong, and the published
port simply never listens: every site is unreachable while the proxy looks
perfectly healthy. start checks for these too and names the process. For Valet,
valet stop frees the ports.
So, under OrbStack: mutual exclusion, until either it honours host IPs or one proxy fronts both stacks. For Altis Local Server, the shared proxy does front both; see An Altis project.
Public DNS needs a network, so nothing resolves on a plane. Add the hosts you
need to /etc/hosts:
127.0.0.2 mysite.example.dev blog.example.dev
That's deliberately manual. A local dnsmasq answering *.example.dev would be
automatic, but it would also hijack the real example.dev and www on your own
machine — it can't wildcard a domain you actually use without shadowing it. It
would also bind :53, which is its own conflict to manage.
.localhost hosts resolve offline with no help at all, which is a reason to
prefer them if you fly a lot.
- Compose interpolates
$. Anything with a dollar sign in a compose file — PHP like$_SERVER, most obviously — is expanded as a variable and silently becomes an empty string, leavingwp-config.phpsyntactically broken and taking wp-cli down with it. Values are escaped ($$) on the way out; seeescapeForCompose(). WORDPRESS_CONFIG_EXTRAis eval'd, so it must be bare PHP with no opening tag, and a syntax error breaks the site and wp-cli.- Never mount a directory at
wp-content. It becomes the parent of every other mount, so Docker creates mountpoints inside the real repo — writing stray files into it, and failing outright for single files. Mount pieces into the container's ownwp-contentinstead. - Every wp-cli call is a container that loads WordPress, which is seconds on a large site. Batch them. An already-installed site should make one call and stop.
- Changing
phporwordpresschanges the image tag, but core lives in a volume that survives.happy-env destroy <site>and start again if a version change doesn't take.
Content. A repo is wp-content, so there's no database in git and
happy-env start gives you an empty WordPress. Uploads have an answer now —
uploads.fallback serves missing media from
production — but the database doesn't. Seeding a sanitized dump is the piece
still missing, and lifecycle hooks (afterInstall) are the shape it probably
wants.
Extra services. Redis and Elasticsearch have nowhere to go today. A
services key merged into the generated compose file is the natural hook;
nothing in the current structure fights it.
xdebug. The official image doesn't ship it, so this needs a derived image.
A project pins a version, so editing environment doesn't change what that repo
runs until the version is bumped and reinstalled. That's the point — a
teammate's npm ci gets byte-identical tooling — but it makes for a slow loop
when you're changing the CLI itself. Either run the working copy directly:
node ../../environment/bin/happy-env.mjs startor npm link it into the project you're testing against.
To release: bump version, commit, tag vX.Y.Z, push the tag, npm publish,
then bump the dependency in each consuming repo.