Tasks¶
The plugin registers a handful of infrastructure tasks, plus every task
contributed by the services you registered. Run castor list to see them all.
Infrastructure¶
castor docker:about¶
Sums up the project — its name, its root domain, how many of its containers run, whether the router is up — then lists every URL the project answers on.
castor docker:about
Alias: castor about.
The URLs are read from the caddy labels of compose.generated.yaml,
compose.yaml and compose.override.yaml, so a domain declared by a service
with withDomain(), by an #[AsDockerComposeBuilder] function or straight in
your own compose file is listed the same way — and each is shown against the
service serving it, with its status:
--------------- ---------------------------------- ---------
Service URL Status
--------------- ---------------------------------- ---------
app https://app.project.test running
https://project.test
http://app.project.test
adminer https://adminer.project.test stopped
--------------- ---------------------------------- ---------
The http:// entries are the services that also allow plain HTTP with
withHttpAccess(); every other domain is served over HTTPS only.
Everything comes from the compose files, so the task answers with the infrastructure stopped, and without a running Docker daemon — only the running/stopped statuses need one.
When public tunnels are open, a second table gives the public URL of each tunnelled domain.
castor docker:build¶
Builds the Docker images of the infrastructure.
castor docker:build # everything
castor docker:build app # a single service
castor docker:build --profiles builder # restrict to a profile
Alias: castor build.
castor docker:up¶
Starts the containers, building the missing images first.
castor docker:up
castor docker:up app
castor docker:up --build # force a rebuild before starting
Alias: castor up. Services exposed over TCP before the last stop are
re-exposed automatically.
castor docker:stop¶
Stops the containers, and the TCP forwarders and public tunnels that went with them.
castor docker:stop
castor docker:stop app
Alias: castor stop.
castor docker:logs¶
Follows the container logs.
castor docker:logs
castor docker:logs app
Alias: castor logs.
castor docker:logs:clear¶
Empties the log files docker keeps for the containers, so docker:logs starts
from a clean slate.
castor docker:logs:clear # every container of the project
castor docker:logs:clear app # a single service
Nothing is restarted: the file is truncated in place, so a running container keeps running and keeps writing to the same stream.
Stopped containers are cleared too, and so are the ones on a profile that is not
active — their logs are still on disk. A container whose logging driver is not
json-file keeps its logs elsewhere and is reported as skipped.
Note
The log file belongs to root, and on Docker Desktop it lives inside the VM
rather than on your machine. When it cannot be written directly, the task runs
a short-lived --privileged container to reach it — that is the only way to
get at it on Docker Desktop.
castor docker:ps¶
Lists the containers and their status. Alias: castor ps.
castor docker:stats¶
Shows what the project costs the machine it runs on. Alias: castor stats.
castor docker:stats
castor docker:stats -v # also lists every image and volume
castor docker:stats --no-disk # skips the disk usage
The first table gives, for every container of the project, its status and what it is consuming right now — CPU, memory, network and block I/O, processes — and totals the columns. The line below it puts those totals in proportion to the cores and the memory the docker host has.
The second table adds up the disk the project takes: its images, the writable layer of its containers, and its volumes. Two numbers are given for each:
- Size is what docker reports for these images, containers and volumes;
- Exclusive is what destroying the project would actually free. Image layers shared with images belonging to other projects only count in Size.
Note
The disk usage makes docker measure every image, container and volume of the
daemon, which takes a few seconds. Pass --no-disk when only the CPU and the
memory matter.
Volumes are recognised by the compose labels they carry. Images carry none, so
they are recognised by name: the <project>-<service> images this plugin
builds, and the images the project containers were started from — which is how
a pulled image such as redis:8 is attributed to the project too.
castor docker:doctor¶
Looks for what keeps the project from working on this machine, and says how to
fix each problem it finds. Alias: castor doctor.
castor docker:doctor
Run it before anything else when something goes wrong: most of the failures
troubleshooting describes are ones it detects — a daemon
that does not answer, a port held by another program, a router that never joined
the project network, a domain that does not resolve, a certificate the browser
does not trust, a directory docker created as root. Each section of the report
is a table, with the fix under what went wrong:
--- ---------- ------------------------------------------------------
Check Result
--- ---------- ------------------------------------------------------
✔ Port 80 Held by the router.
✘ Port 443 Held by nginx (pid 812): the router cannot bind it.
→ Stop nginx (pid 812).
--- ---------- ------------------------------------------------------
A ✘ is enough on its own to keep the project from working, and makes the task
exit with a non-zero code. A ⚠ leaves it working, but not as it should: a
certificate the browser warns about, a router running an older configuration
than your project's. A – is a check that could not run, because what it needs is missing and
reported elsewhere: without a Docker daemon, the daemon is what the report is
about, not every check that needed it. So the task answers with nothing running,
and with no daemon at all.
Docker. Compose has to be 2.23.1 or later: 2.20 for the include: of your
compose.yaml, 2.23.1 for the compose file of the router, which inlines its
Caddyfile. Compose 2.40.2 and later build through Buildx and refuse one older
than 0.17, so a missing Buildx breaks docker:build there — and docker:push
everywhere. When the context sets a registry, the doctor also makes sure the
builder can export the build cache docker:push pushes. It measures the disk the
daemon writes to when the daemon runs on this machine; Docker Desktop and Colima
keep theirs in a VM, out of reach.
Project. Compose has to accept your compose files, which catches a mistake in
compose.override.yaml before docker:up does. A container that keeps
restarting, turned unhealthy or crashed is reported with the docker:logs to
read. The containers run as user_id, which should own the project and every
directory it mounts: otherwise they write files you cannot change, or cannot
write at all.
Router. It has to run when the project does, with a configuration at least as recent as the one of your project — see keeping it up to date — and to have joined the project network. It also has to watch the socket of the daemon your projects run on: the doctor compares it with the one docker talks to, since a router watching another daemon serves nothing without failing.
Ports. 80 and 443, when the project routes a domain, and every host port it publishes: each one must be free, or held by the router or by the project itself. Otherwise the doctor names the container or the process holding it.
HTTPS and DNS. mkcert has to be installed, its CA copied to the router and
trusted by the system, and every domain docker:about
lists has to resolve to this machine. Under WSL, the browser runs on Windows,
which keeps a trust store and a hosts file of its own: the doctor asks Windows
too, through certutil.exe and powershell.exe.
Worktree. In a worktree, it lists what the
checkout still shares with the other ones — a domain outside of its root domain,
a host port — as docker:about does.
castor docker:destroy¶
Removes containers, volumes and networks of the project, and closes its public
tunnels. Destroys your data, so it asks for confirmation unless --force is
given.
castor docker:destroy
castor docker:destroy --force
castor docker:push¶
Pushes the images and their build cache to the registry configured in the
registry context variable. Only the services declaring a cache_from are
built, and docker buildx bake reads the compose file itself, so what it builds
is exactly what castor docker:build builds.
Each service lands in one repository, holding its cache under the cache tag
and its image under latest — --tag publishes it under another name. The
image carries org.opencontainers.image.source, which is what
attaches the package to your repository.
--dry-run prints the build plan bake resolved, without running it.
castor docker:push
castor docker:push --tag "$(git rev-parse --short HEAD)"
castor docker:push --dry-run
Publishing to ghcr.io¶
GitHub attaches a package to a repository in two cases only: a push from a
workflow authenticating with GITHUB_TOKEN, or a push carrying the
org.opencontainers.image.source label. A build cache carries no label — the
manifest has nowhere to hold one — so a push from a laptop used to leave an
orphan package behind, owned by whoever pushed it first and writable by them
alone. The CI could then no longer feed the cache it was supposed to own: its
GITHUB_TOKEN has no permission on a package attached to nothing.
The image pushed next to the cache is what carries the label, so the package is attached no matter who pushes it, and it inherits the permissions of the repository — push rights on the repository are push rights on its images.
The repository is looked up in this order, the first one that answers winning:
| Source | Example |
|---|---|
the repository context variable |
'repository' => 'mycompany/myproject' |
GITHUB_REPOSITORY, which Actions sets |
mycompany/myproject |
the origin git remote |
git@github.com:mycompany/myproject.git |
The task refuses to push to ghcr.io when none of them answers, rather than
create a package nobody can take over afterwards.
Packages pushed before this, or from a registry namespace that does not match the repository owner, stay orphans: delete them, or attach them by hand in Package settings › Connect repository, once.
Completing a service name¶
Every argument naming something completes, and each one offers the right list:
| Task | Completes with |
|---|---|
docker:build, docker:up, docker:stop, docker:logs, docker:logs:clear |
the containers of the compose files |
docker:service:remove |
the services registered in your castor.php |
docker:service:install |
the services the plugin knows how to install |
{app}:worker:restart, {app}:worker:stop |
the workers of that application |
docker:tunnel:start, docker:tunnel:stop |
the domains the project routes |
castor docker:logs app<TAB> # app1 app1-builder app1-worker-messenger
castor docker:service:remove <TAB> # app1 app2 postgres redis …
castor app1:worker:restart <TAB> # messenger
The container names are read from compose.generated.yaml, compose.yaml and
compose.override.yaml, so the services you declare yourself are offered
alongside the generated ones, and completion answers without a running Docker
daemon.
Install the shell completion once with castor completion | source — see the
castor documentation.
Services¶
castor docker:service:install¶
Registers a service in your castor.php, then builds and starts it. See
installing services.
castor docker:service:remove¶
Unregisters a service and tears down its containers.
Exposing a service over TCP¶
Databases and brokers expose a {service}:expose task, to reach them from the
host with a native client — the router only handles HTTP and HTTPS.
castor postgres:expose # on the service default port
castor postgres:expose 15432 # on a specific host port
castor postgres:expose --stop # stop the forwarder
Exposed services are remembered across docker:stop / docker:up, per
checkout: a worktree restores its own forwarders
and not the ones of the main checkout.
A host port belongs to the machine, so two checkouts cannot publish the same one.
The task says which container holds it, and remembers the request: the forwarder
comes back on the next docker:up, once the port is free again.
Dumping and restoring a database¶
Every database service has a {service}:dump and a {service}:restore task, to
write its content to a file and to replace it with the one of a dump — see
databases.
castor postgres:dump prod.sql.zst
castor postgres:restore prod.sql.zst
castor mysql:dump > dump.sql
gunzip -c prod.sql.gz | castor mysql:restore
Worktrees¶
Every checkout of the repository is a stack of its own — see git worktrees.
castor worktree:list— every checkout, its branch, its compose project, the state of its stack and its URLcastor worktree:create <name>— create one,--startto build and start its stack,--copy-datato start from the databases of the current checkout,--branchand--fromto pick the branchcastor worktree:delete <name>— destroy its stack and remove it, keeping the branch
Every task also takes a --worktree <name> (main for the main checkout) to run
in another checkout.
Router¶
The router is global and shared by every project, so its tasks are not tied to the current one — see router and HTTPS.
docker:up starts it when the project routes a domain, and docker:stop and
docker:destroy stop it once no routed container is left running on the
machine. These tasks are for the times you want to decide yourself:
castor docker:router:enable— create, start and trust the router, copying the mkcert CA if availablecastor docker:router:status— whether it runs, whether the autostart is on, and the projects it servescastor docker:router:logs— its logs,--followto tail themcastor docker:router:restart— restart itcastor docker:router:disable— stop it
Set the router_autostart context variable
to false, or CASTOR_DOCKER_ROUTER_AUTOSTART=0 for a single command, to leave
the router entirely to those tasks.
Sharing the project over a public tunnel¶
castor docker:tunnel:start¶
Gives the domains of the project a public HTTPS URL. Use it to show a colleague or a client what you are working on, to open the project on a phone, or to receive the webhooks of a third-party service.
castor docker:tunnel:start # every domain of the project
castor docker:tunnel:start app.myproject.test # a single one
castor docker:tunnel:start app.myproject.test myproject.test # or a few
castor tunnel # the same, shorter
--------- -------------------- --------------------------------------------------- ---------
Service Domain Public URL Status
--------- -------------------- --------------------------------------------------- ---------
app app.myproject.test https://calm-river-sample-words.trycloudflare.com running
myproject.test https://other-sample-words.trycloudflare.com running
--------- -------------------- --------------------------------------------------- ---------
The tunnels are Cloudflare quick tunnels,
opened by a cloudflared container: there is nothing to install and no account
to create. A quick tunnel gets one random *.trycloudflare.com URL, so each
domain gets a container and a URL of its own.
The tunnels run in the background. Running the task again prints the URLs of the
ones already open and opens the missing ones. docker:about lists the open ones
too. A tunnel lasts until:
castor docker:tunnel:stopcloses it.castor docker:tunnel:stop <domain>...closes only the tunnels of the domains given;castor docker:stoporcastor docker:destroytake the project down;- the router stops or restarts, since the tunnels go through it.
A tunnel that was closed never comes back with the same URL. Open it again and it gets a new one.
Warning
Anyone with a URL reaches the service behind it, with no authentication in between. Without an argument the task tunnels every domain of the project, including tools such as Adminer or RabbitMQ when they have one. Name the domains to share only those.
How the traffic reaches your application¶
Each cloudflared container joins the network of the global router
and forwards the requests to it over HTTPS. It rewrites the Host to the local
domain, so the router serves the request the way it serves your browser, with
the same service behind it, whether it allows plain HTTP or not. Domains
declared in compose.override.yaml can be tunnelled as well.
The public host name travels in the X-Forwarded-Host header, and the router
passes it through untouched. Your application sees Host: app.myproject.test,
and X-Forwarded-Host: calm-river-sample-words.trycloudflare.com. To generate
its absolute URLs and redirections with the public host, it has to trust the
router as a proxy. For Symfony:
# config/packages/framework.yaml
framework:
trusted_proxies: 'private_ranges'
trusted_headers: ['x-forwarded-for', 'x-forwarded-host', 'x-forwarded-proto', 'x-forwarded-port']
Without it, the application keeps seeing its local domain. Relative links work either way.
Note
A router started by an older version of the plugin overwrites
X-Forwarded-Host with the local domain. docker:up and
docker:tunnel:start warn about it: run castor docker:router:restart once
to pick up the new configuration.
Quick tunnels come with limits of their own: no Server-Sent Events, at most 200
requests in flight, and no uptime guarantee. Cloudflare also rate-limits how
many can be created. When one could not be, the task prints what cloudflared
said, and running it again retries only the missing tunnels.
castor docker:tunnel:stop¶
Closes the tunnels of the project, or the tunnels of the domains given.
castor docker:tunnel:stop
castor docker:tunnel:stop app.myproject.test myproject.test
Both tasks complete the domain names, and leave out the ones already given.
Profiles¶
Services are organised into Docker Compose profiles:
default— the services started by defaultbuilder— build and CI/CD containers
Every infrastructure task takes --profiles:
castor docker:up --profiles default
castor docker:build --profiles builder
Set the docker_profiles context variable
to change what the tasks activate when you pass none.
Running a command in a container¶
Castor\Docker\docker_compose_run() is what the service tasks are built on, and
what your own tasks should use to reach a container:
use function Castor\Docker\docker_compose_run;
docker_compose_run(['bin/console', 'app:import'], 'app-builder');
docker_compose_run(
['bin/replay', '--verbose'],
service: 'agent',
workDir: '/app/agent',
environment: ['RUST_LOG' => 'debug'], // -e RUST_LOG=debug
entrypoint: '/bin/bash', // --entrypoint
ports: ['10080:10080', '28080:8080/tcp'], // -p
);
Give the command as a list of tokens. They reach docker as they are, so nothing
splits or expands them, and an argument holding a space, a quote, a $ or a
; arrives whole — which is what you want as soon as an argument comes from
whoever typed the task:
// the message arrives as one argument, and $USER is not expanded
docker_compose_run(['bin/console', 'app:notify', 'deployed by $USER'], 'app-builder');
A string is still accepted, and still runs through a shell in the container, which is what a command written to use one needs:
docker_compose_run('bin/console app:import | tee /tmp/import.log', 'app-builder');
A failing command raises a RuntimeException naming the service and the command
that broke, instead of the bare docker compose error. Use
docker_exit_code(), which takes the same arguments, when you want the exit
code rather than an exception.
In a container already running¶
docker_compose_run() starts a throwaway container. To reach the one a service
is already running — to look at a cache it has warmed, to send it a signal —
use docker_compose_exec():
use function Castor\Docker\docker_compose_exec;
docker_compose_exec(['bin/console', 'cache:pool:list'], 'app');
docker_compose_exec(['apt-get', 'update'], 'app', privileged: true);
It takes the same workDir and environment arguments, and the same two
command forms. The service has to be up: docker compose exec fails on a
service that is not running, where docker_compose_run() would have started
one.
Compose announces the throwaway container each run creates —
Container app-builder-run-8c9d8bef Creating, then Created — in front of the
output you asked for. Those lines are silenced, and come back as soon as you run
a task with -v.