Repository navigation
Expand file tree
/
Copy pathDockerfile
More file actions
264 lines (221 loc) · 10.3 KB
/
Copy pathDockerfile
File metadata and controls
264 lines (221 loc) · 10.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
# syntax=docker/dockerfile:1.7
# Maildun runtime image.
#
# One image serves all three long-running processes. docker-compose.yml starts
# it three times with different commands: the web server, Horizon, and the
# scheduler. Building once keeps their code and dependencies identical, which
# matters because a queue worker running older code than the web process fails
# in ways that are hard to read.
ARG PHP_VERSION=8.4
########################################
# Base: the PHP platform every stage shares
########################################
# Dependency resolution, the asset build, and the runtime all derive from this
# single stage so Composer validates platform requirements against exactly the
# extension set the application will run on. Resolving against a leaner image
# is how you end up shipping a lock file the runtime cannot satisfy.
#
# pcntl and posix are what Horizon uses to supervise and signal its workers,
# and laravel/horizon declares ext-pcntl, so dependency resolution fails
# without it. gd carries the WebP encoder that
# App\Actions\Media\ConvertImageToWebp calls. redis is phpredis, which
# config/database.php selects as the Redis client.
FROM dunglas/frankenphp:1-php${PHP_VERSION} AS base
RUN install-php-extensions \
gd \
intl \
opcache \
pcntl \
pdo_mysql \
pdo_pgsql \
pdo_sqlite \
redis \
zip
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
########################################
# 1. PHP dependencies
########################################
FROM base AS vendor
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
COPY composer.json composer.lock ./
# Resolve dependencies before the source lands so this layer survives ordinary
# code edits. --no-scripts and --no-autoloader because post-autoload-dump runs
# artisan, which needs application code that is not here yet.
RUN --mount=type=cache,target=/tmp/composer-cache \
COMPOSER_CACHE_DIR=/tmp/composer-cache composer install \
--no-dev \
--no-scripts \
--no-autoloader \
--no-interaction \
--prefer-dist \
--no-progress
# The classmap has to be built where both Composer and the source exist. The
# runtime image ships neither Composer nor a package manager, so it cannot
# generate this itself.
#
# --optimize without --classmap-authoritative: the classmap still resolves
# every application class directly, but PSR-4 stays available as a fallback
# rather than turning a missed entry into a fatal "class not found".
#
# Package discovery is deliberately left to first boot, where the entrypoint's
# config:cache performs it and writes the manifest to bootstrap/cache.
COPY . .
RUN composer dump-autoload --no-dev --optimize
########################################
# 2. Frontend assets
########################################
# This stage needs Node as well as PHP: vite.config.ts runs the wayfinder
# plugin, which shells out to `php artisan wayfinder:generate` to emit the
# typed route helpers that resources/js imports, and those directories are not
# committed. A Node-only stage cannot build this application.
FROM base AS assets
# Both images are Debian bookworm, so Node's prefix transplants cleanly and
# pins the version without adding a third-party apt repository.
COPY --from=node:22-bookworm-slim /usr/local/ /usr/local/
# The Node image's yarn symlinks resolve outside /usr/local, so copying that
# prefix alone leaves them dangling. `corepack enable` calls realpath over
# every shim it manages and dies on them. Drop the links, and enable only
# pnpm, which is the package manager this project declares.
RUN rm -f /usr/local/bin/yarn /usr/local/bin/yarnpkg \
&& corepack enable pnpm \
&& pnpm --version
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
COPY patches ./patches
RUN --mount=type=cache,target=/pnpm-store \
pnpm config set store-dir /pnpm-store \
&& pnpm install --frozen-lockfile
# The MCP email builder tools run resources/js/lib/email-builder-agent.mjs
# with Node on every request (App\Services\EmailBuilderAgent), so the runtime
# image needs the packages that script loads. It imports only the core, agent
# and compat entry points of @maildun/email-builder, and those import only zod
# and marked; the editor half and its React dependencies never load. Shipping
# those three packages instead of the production node_modules (about 350 MB)
# keeps the image small. They are copied, symlinks resolved, from the
# builder's own pnpm dependency directory, so they are exactly the versions
# the lockfile resolved for it. The docker job in .github/workflows/tests.yml
# runs the agent inside the built image, so a new import fails CI, not a
# deployment.
RUN builder="$(realpath node_modules/@maildun/email-builder)" \
&& mkdir -p /email-builder-runtime/node_modules/@maildun \
&& cp -RL "$builder" /email-builder-runtime/node_modules/@maildun/email-builder \
&& for dependency in marked zod; do \
cp -RL "$builder/../../$dependency" "/email-builder-runtime/node_modules/$dependency"; \
done
COPY --from=vendor /app/vendor ./vendor
COPY . .
# .dockerignore keeps local runtime state out of the context, so these are
# absent here. Booting artisan without them fails while resolving the log and
# cache paths, which is what the wayfinder plugin runs into.
RUN mkdir -p \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache
# Generating the helpers explicitly, with the same --with-form the vite plugin
# passes, is purely for legibility: the plugin reports a failure here as
# "Command failed" with the underlying artisan error discarded. The build
# regenerates them immediately afterwards, so this cannot drift from what the
# plugin produces. Passing the bare command without --with-form would omit the
# form variants and break type-checking in the pages that import them.
#
# A throwaway key satisfies the bootstrap. It is never written to the image:
# this stage contributes nothing but public/build to the final layer. Wayfinder
# reads routes only and never opens a database connection.
RUN export APP_KEY=base64:$(head -c 32 /dev/urandom | base64) \
&& php artisan wayfinder:generate --with-form \
&& pnpm run build
########################################
# 3. Runtime
########################################
FROM base AS runtime
# The code belongs to root, so the server cannot change it; only the
# directories Laravel writes to belong to www-data. A writable application
# root would also let app:install create a .env from .env.example underneath
# an environment the platform manages (Coolify, Managed WP).
COPY . .
COPY --from=vendor /app/vendor ./vendor
COPY --from=assets /app/public/build ./public/build
# The email builder agent's runtime (see the assets stage): the Node binary
# alone, without npm, corepack or headers, and the three packages it loads.
# Node's license has to travel with its binary. The official image unpacks the
# release tarball over /usr/local, which leaves it at /usr/local/LICENSE; the
# three packages keep theirs inside their own directories.
COPY --from=node:22-bookworm-slim /usr/local/bin/node /usr/local/bin/node
COPY --from=node:22-bookworm-slim /usr/local/LICENSE /usr/local/share/doc/node/LICENSE
COPY --from=assets /email-builder-runtime/node_modules ./node_modules
# Directories Laravel writes to at runtime. docker-compose.yml mounts a volume
# over storage/, so these are the fallback for a plain `docker run`. The
# storage:link symlink is made here too, because public/ is read-only at
# runtime; it serves uploads on the local disk (FILESYSTEM_DISK=local).
RUN mkdir -p \
storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache \
&& ln -s /app/storage/app/public public/storage \
&& chown -R www-data:www-data storage bootstrap/cache
# The entrypoint locks with flock, the healthcheck requests with curl, and the
# MCP email builder runs on node. Running node also proves the copied binary
# finds its shared libraries on this base.
RUN command -v flock curl \
&& node --version
COPY <<'PHPINI' /usr/local/etc/php/conf.d/maildun.ini
; Campaign HTML and contact imports both exceed the stock 2M ceiling.
upload_max_filesize = 32M
post_max_size = 32M
memory_limit = 512M
opcache.enable = 1
opcache.memory_consumption = 192
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0
opcache.interned_strings_buffer = 16
PHPINI
COPY <<'ENTRYPOINT' /usr/local/bin/maildun-entrypoint
#!/bin/sh
set -e
if [ -z "${APP_KEY}" ]; then
echo "APP_KEY is not set." >&2
echo "Generate one with: docker compose run --rm --entrypoint php app artisan key:generate --show" >&2
echo "It encrypts stored workspace mail credentials and automation tokens." >&2
echo "Losing or changing it makes existing encrypted values unreadable." >&2
exit 1
fi
mkdir -p \
storage/app/public \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
bootstrap/cache
# Only the web service sets this. Running migrations from three services that
# start together races them against each other.
if [ "${RUN_MIGRATIONS}" = "true" ]; then
php artisan migrate --force --no-interaction
fi
# Passport signs API tokens with these. They live under storage/, which is a
# volume, so they survive restarts; regenerating them invalidates every token.
# The web server, Horizon and the scheduler share that volume and start
# together, so the lock lets exactly one of them create the pair.
flock storage/.passport-keys.lock sh -c \
'[ -f storage/oauth-private.key ] || php artisan passport:keys --no-interaction || true'
php artisan config:cache
php artisan route:cache
php artisan view:cache
exec "$@"
ENTRYPOINT
RUN chmod +x /usr/local/bin/maildun-entrypoint
# Binding above 1024 keeps the server off root and out of needing
# CAP_NET_BIND_SERVICE. Publish it as :80 on the host in compose.
ENV SERVER_NAME=:8080
USER www-data
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD curl -fsS http://127.0.0.1:8080/up || exit 1
ENTRYPOINT ["maildun-entrypoint"]
CMD ["frankenphp", "php-server", "--root", "public/", "--listen", ":8080"]