Installing Manager Self-Hosted
Manager for Craft is a control plane for a fleet of Craft CMS installations. It holds no administrator password, no SSH credential and no site database password - there is nowhere in its schema to put one - so the main thing to get right at install time is the platform's own security.
Before you start: is self-hosting the right choice?
Self-hosted Manager for Craft is free, complete, and yours. Every monitoring, findings, jobs and backup feature is here - there is no reduced edition and no feature held back. If you want to run it, run it.
Be clear-eyed about what you are taking on, though, because it is a security-sensitive service holding the keys to your clients' databases:
- A server, kept patched. Docker, a reverse proxy, TLS certificates that renew.
- Postgres and Redis, backed up and monitored. Redis failing closed means connectors stop being trusted, which is the correct behaviour and still an outage.
- Two keypairs and
APP_KEY, backed up separately from the database. Lose the signing key and every site needs re-pairing. Lose the backup key and every stored backup is permanently unreadable, deliberately, with no recovery path. - The backup store. A copy of every managed site's database, which is the most sensitive thing you will hold anywhere.
- Upgrades, on your schedule, including reading the release notes before running migrations.
- Somebody on call, because a monitoring system nobody watches is decoration.
That is an afternoon to install and an ongoing responsibility to run. Plenty of people want exactly that, and this documentation is written for them.
If you would rather not take it on, a hosted option exists at managerforcraft.com - same code, same boundaries, with the server and the on-call rota ours rather than yours. Nothing on this page is affected either way, and moving between the two means re-pairing sites rather than rebuilding anything.
Requirements
| Docker | Engine 24+ with the Compose plugin |
| PostgreSQL | 15+. Not MySQL: the audit log relies on a trigger and on privileges that are not portable |
| Redis | 7+. Backs replay protection, which fails closed - if Redis is unreachable, connector requests are rejected rather than accepted |
| TLS | Mandatory. Signed requests protect integrity and replay, not confidentiality |
Two CPUs and 2 GB of memory is comfortable for a few dozen sites.
Install
git clone https://github.com/Coysh-Digital/manager.git manager
cd manager/deploy/docker
cp ../../.env.example .envClone it wherever you like. Nothing in the build refers to the path - the compose file's build context is relative - so the only requirement is a directory you can write to as the user running Docker. /opt/manager is a reasonable convention on a server, and needs root to create on a fresh one. The rest of the documentation writes /path/to/manager and means whatever you chose here.
Edit .env. At minimum set APP_KEY, APP_URL and DB_PASSWORD - the container refuses to start without them, and refuses to start at all on a well-known default password, with APP_DEBUG on, or with APP_ENV set to anything but production. Every variable is documented in env.md.
None of those refusals is conditional on APP_ENV, and the example ships the safe values, so there is no combination of settings that skips them quietly.
Generate a key:
docker compose run --rm --no-deps app php artisan key:generate --showKeys
Manager for Craft needs two keypairs beyond APP_KEY, and they are separate on purpose: one signs responses to connectors, the other encrypts backups. Using one keypair for both would weaken both.
Generate them before starting the stack, and put them in .env yourself:
docker compose run --rm --no-deps app php artisan manager:keys:generate --show
docker compose run --rm --no-deps app php artisan manager:backups:keygen --showEach prints two lines. Add all four to .env.
They are printed rather than saved because the container has no writable .env - the environment arrives from this file and the container's root filesystem is read-only, which is deliberate. If you run these against a container that does have a writable .env, they write to it and say so.
Back both secret keys up with your other application secrets, and keep the backup key somewhere other than alongside the backups themselves. Losing the signing key means re-pairing every site; losing the backup key makes every stored backup permanently unreadable, with no recovery path. See backup.md.
Start it
docker compose up -d
docker compose exec app php artisan manager:doctorThe stack binds 127.0.0.1:8080, and nothing else - TLS is the reverse proxy's job, so see reverse-proxy.md before pointing a browser at it. If something on the host already holds 8080, docker compose up stops with Bind for 127.0.0.1:8080 failed: port is already allocated; set MANAGER_HTTP_PORT in this same .env and run it again. The port inside the container does not move, so the proxy examples still apply - only their upstream number changes.
APP_URL is a separate question from that port, and the two are only the same number when nothing is in front of the application. On a server it is the public HTTPS address the proxy answers on, and changing MANAGER_HTTP_PORT does not affect it. Evaluating on a laptop with no proxy, it has to carry the port you chose - http://localhost:8543 and not http://localhost - or links, cookies and the setup screen are built against an address nothing is listening on. manager:doctor reports what it is set to.
If nobody can log in
The setup route closes permanently once an account exists, and the password reset flow needs working mail - which a fresh installation may not have. So there is a way in from the server:
docker compose exec app php artisan manager:user:password [email protected] --generateIt prints a strong password once. Add --reset-second-factor if you have also lost the authenticator, which is a separate flag on purpose: a password reset does not remove multi-factor authentication, and a command that did both quietly would be a way to strip it from any account.
Both are recorded in the audit log. Neither the password nor its hash is.
This grants nothing new - anybody who can run it already has the database and APP_KEY, and therefore the installation. It just means you do not have to edit a password hash by hand to get back in.
manager:doctor must report no failures. It checks the things that are easy to get wrong and expensive to discover later: a wildcard trusted-proxy setting, a non-atomic replay store, missing audit-log triggers, an insecure session cookie, a superuser database role.
Put a reverse proxy in front - see reverse-proxy.md - and only then visit /setup.
First run
/setup creates the organisation and its owner. It closes permanently once an account exists: the route stops resolving, so there is nothing left to probe for.
Until you complete it, anyone who can reach the installation can create the first owner.manager:doctor warns while it is open. Either complete setup immediately or keep the installation unreachable until you have.
Set up two-factor authentication straight afterwards. Manager for Craft will prompt you.
Adding a site
Create the site in Manager for Craft, recording the domain you expect it to pair from.
Copy the enrolment code. It is shown once and expires in fifteen minutes.
On the Craft installation:
bashcomposer require coysh-digital/craft-manager-connector php craft plugin/install manager-connector php craft manager-connector/pair mgr_enrol_...
That is the whole of it. The site reports from here on without anything further being set up.
Cron is optional, and is a recommendation rather than a requirement. The plugin runs its schedule off ordinary web traffic by default, which works on hosting that has no cron at all. What cron changes is when, not whether: off web traffic a task fires on the first request after its interval has elapsed, so a site with no overnight visitors reports in the morning rather than overnight. If exact timing matters, The Craft plugin has the full crontab and the setting to turn off first.
If the connector pairs from a host that differs from the one you recorded, pairing is held and nothing is reported until you confirm it. That is deliberate: it is the check that catches a request coming from somewhere you did not expect.
Using managed services
Nothing here assumes Postgres and Redis are local. Point DB_HOST and REDIS_HOST at the managed equivalents and delete those two services from compose.yaml. Restrict network access to the database so only Manager for Craft can reach it.
Least-privilege database role
The audit log is protected by a trigger that holds even against the table owner. As defence in depth, connect as a role that cannot rewrite it at all:
CREATE ROLE manager_app LOGIN PASSWORD 'a-strong-password';
GRANT CONNECT ON DATABASE manager TO manager_app;
GRANT USAGE ON SCHEMA public TO manager_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO manager_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO manager_app;
-- The point of the exercise.
REVOKE UPDATE, DELETE, TRUNCATE ON audit_events FROM manager_app;Migrations need a more privileged role; run them separately. manager:doctor warns if Manager for Craft is connecting as a superuser, because a superuser bypasses privilege checks entirely.
Ports
| Direction | Purpose |
|---|---|
| Inbound 443 | Browsers, and connectors reporting in |
| Outbound 443 | Release and advisory checks |
| Internal 5432, 6379 | Postgres and Redis. Never expose these |
Managed sites need no inbound firewall rule. Manager can knock on one to ask it to check in early, but nothing depends on that reaching the site: one it cannot reach keeps its own schedule, and a requested backup starts at the next check-in instead of within seconds. Set MANAGER_NUDGE_ENABLED=false if this installation must make no outbound request to a managed site at all.