Hardened the Docker build of the Website service target.
This commit is contained in:
@@ -10,6 +10,9 @@
|
|||||||
**/.build
|
**/.build
|
||||||
**/.swiftpm
|
**/.swiftpm
|
||||||
|
|
||||||
|
# Test sources
|
||||||
|
**/Tests
|
||||||
|
|
||||||
# Xcode project (not used by the Linux build)
|
# Xcode project (not used by the Linux build)
|
||||||
*.xcodeproj
|
*.xcodeproj
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ HOST_USER=
|
|||||||
# Name of the Docker image to pull/run.
|
# Name of the Docker image to pull/run.
|
||||||
IMAGE_NAME=website
|
IMAGE_NAME=website
|
||||||
|
|
||||||
# Platform of the Docker image to pull/run.
|
# Platform of the deployment image
|
||||||
IMAGE_PLATFORM=linux/arm64
|
IMAGE_PLATFORM=linux/amd64
|
||||||
|
|
||||||
|
# Platform of the *local* build only (docker-compose.override.yml)
|
||||||
|
BUILD_PLATFORM=linux/arm64
|
||||||
|
|
||||||
# Tag of the image to pull/run.
|
# Tag of the image to pull/run.
|
||||||
# Use a semver in production; avoid `latest` so rollbacks are unambiguous.
|
# Use a semver in production; avoid `latest` so rollbacks are unambiguous.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# ================================
|
# ================================
|
||||||
# Asset image
|
# Asset image
|
||||||
# ================================
|
# ================================
|
||||||
FROM node:22-alpine AS assets
|
FROM node:22-alpine@sha256:c610fcdfb1d5b4740dd70c284ed3cb16bb857e0f7166196e36a5501df7a3aa32 AS assets
|
||||||
|
|
||||||
ARG ESBUILD_VERSION=0.28.1
|
ARG ESBUILD_VERSION=0.28.1
|
||||||
ARG OXIPNG_VERSION=9.1.5
|
ARG OXIPNG_VERSION=9.1.5
|
||||||
@@ -30,7 +30,7 @@ COPY --from=assets /static /
|
|||||||
# ================================
|
# ================================
|
||||||
# Build image
|
# Build image
|
||||||
# ================================
|
# ================================
|
||||||
FROM swift:6.3-noble AS build
|
FROM swift:6.3-noble@sha256:69bf1f0281e13d82c9e49d67c2dd1dcc8c00bad738c860f4323d7078787ec8ea AS build
|
||||||
|
|
||||||
# Install OS updates
|
# Install OS updates
|
||||||
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||||
@@ -59,10 +59,15 @@ COPY ./Packages/Localization/Sources ./Packages/Localization/Sources
|
|||||||
COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources
|
COPY ./Packages/Persistence/Sources ./Packages/Persistence/Sources
|
||||||
COPY ./Packages/Utility/Sources ./Packages/Utility/Sources
|
COPY ./Packages/Utility/Sources ./Packages/Utility/Sources
|
||||||
COPY ./Services/Website/Sources ./Services/Website/Sources
|
COPY ./Services/Website/Sources ./Services/Website/Sources
|
||||||
COPY ./Services/Website/Tests ./Services/Website/Tests
|
|
||||||
|
|
||||||
# Build the application, with optimizations, with static linking, and using jemalloc
|
# Build the application, with optimizations, with static linking, and using jemalloc.
|
||||||
RUN swift build --package-path ./Services/Website -c release \
|
#
|
||||||
|
# SPM validates the path of every target in the root package — including the test targets — even when only the
|
||||||
|
# executable product is built, so those directories have to exist. They are created empty instead of copied: the test
|
||||||
|
# sources are not needed to compile the product, keeping them out means editing a test never invalidates this layer,
|
||||||
|
# and it keeps the local database bind mount under Tests/DB out of the image entirely.
|
||||||
|
RUN mkdir -p ./Services/Website/Tests/App ./Services/Website/Tests/Library \
|
||||||
|
&& swift build --package-path ./Services/Website -c release \
|
||||||
--product "Website" \
|
--product "Website" \
|
||||||
--static-swift-stdlib \
|
--static-swift-stdlib \
|
||||||
-Xlinker -ljemalloc
|
-Xlinker -ljemalloc
|
||||||
@@ -89,7 +94,7 @@ RUN chmod -R a-w ./Resources
|
|||||||
# ================================
|
# ================================
|
||||||
# Run image
|
# Run image
|
||||||
# ================================
|
# ================================
|
||||||
FROM ubuntu:noble
|
FROM ubuntu:noble@sha256:561618e2c15bf2397621dd04f96926663a3b5616c189cf7e38db7e82f5c538ea
|
||||||
|
|
||||||
# Make sure all system packages are up to date, and install only essential packages.
|
# Make sure all system packages are up to date, and install only essential packages.
|
||||||
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
RUN export DEBIAN_FRONTEND=noninteractive DEBCONF_NONINTERACTIVE_SEEN=true \
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ ast-minify: ## Preview the minified JS/CSS assets in .build/minified
|
|||||||
.PHONY: img-check
|
.PHONY: img-check
|
||||||
img-check: ## Check the production image builds
|
img-check: ## Check the production image builds
|
||||||
@docker build \
|
@docker build \
|
||||||
--platform linux/amd64 \
|
--platform $(IMAGE_PLATFORM) \
|
||||||
--file Dockerfile \
|
--file Dockerfile \
|
||||||
../..
|
../..
|
||||||
|
|
||||||
|
|||||||
@@ -246,7 +246,20 @@ cd ../../Packages/Persistence && POSTGRES_TEST_HOST=127.0.0.1 swift test # again
|
|||||||
`POSTGRES_TEST_NAME`/`USERNAME`/`PASSWORD` each default to `site` (and `POSTGRES_TEST_PORT` is optional), so pass the password explicitly when the local container was initialised with a different `DATABASE_PASSWORD`.
|
`POSTGRES_TEST_NAME`/`USERNAME`/`PASSWORD` each default to `site` (and `POSTGRES_TEST_PORT` is optional), so pass the password explicitly when the local container was initialised with a different `DATABASE_PASSWORD`.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
The production image is built in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080`. `make img-check` pins the build to `linux/amd64`; `make img-release` builds for `IMAGE_PLATFORM`.
|
The production image is built in release mode with a statically linked Swift runtime and jemalloc, runs as a non-root `hummingbird` user, and exposes port `8080`.
|
||||||
|
|
||||||
|
`IMAGE_PLATFORM` is the single source of truth for the deployment architecture: `make img-check` and `make img-release` both build for it, and `docker-compose.yml` runs the pulled image with it. Keep it matched to the deployment host — the three have to agree, or a release builds for one architecture and the production Compose file refuses to run it. Local development builds are separate and follow `BUILD_PLATFORM` (see `docker-compose.override.yml`), since they target your machine rather than the deployment.
|
||||||
|
|
||||||
|
The test sources are deliberately absent from the image. SPM validates the path of every target in the root package — including the test targets — even when only the executable product is built, so the Dockerfile creates those two directories empty rather than copying them. That keeps test edits from invalidating the release-build layer, and keeps the local database bind mount under `Tests/DB` out of the build context entirely (`.dockerignore` excludes `**/Tests` for the same reason).
|
||||||
|
|
||||||
|
### Base images
|
||||||
|
All three stages pin their base by digest as well as tag, so a rebuild of an old commit resolves the same bases it originally used. The trade-off is that they no longer pick up upstream rebuilds on their own: **refresh the digests deliberately**, on whatever cadence you patch on, with
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker buildx imagetools inspect swift:6.3-noble --format '{{.Manifest.Digest}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Both build stages are discarded — only `ubuntu:noble` ships — and the build and run stages each `apt-get dist-upgrade`, so OS packages are patched at build time regardless of the pin's age. What a stale pin holds back is the base layer itself.
|
||||||
|
|
||||||
The executable is the `ENTRYPOINT` and its serving flags are the `CMD`:
|
The executable is the `ENTRYPOINT` and its serving flags are the `CMD`:
|
||||||
```dockerfile
|
```dockerfile
|
||||||
@@ -256,7 +269,7 @@ CMD ["--http-host", "0.0.0.0", "--http-port", "8080"]
|
|||||||
The split is what makes the migrate-and-exit invocation below work: `docker compose run --rm website --database-migrate` replaces the `CMD` flags without having to override the entrypoint.
|
The split is what makes the migrate-and-exit invocation below work: `docker compose run --rm website --database-migrate` replaces the `CMD` flags without having to override the entrypoint.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make img-check # verify it builds for linux/amd64, without tagging or publishing
|
make img-check # verify it builds for IMAGE_PLATFORM, without tagging or publishing
|
||||||
make img-release version=1.2.3 # build, tag, and push a release (an explicit version is required)
|
make img-release version=1.2.3 # build, tag, and push a release (an explicit version is required)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -269,7 +282,7 @@ docker compose -f docker-compose.yml up -d
|
|||||||
### Static assets
|
### Static assets
|
||||||
`Resources/Static` holds the site's stylesheets and scripts under `css/` and `js/`, paired by name: `shared.*` is loaded by every page, alongside a per-page `index.*` and `not-found.*`. The remaining files — the icons, `site.webmanifest`, `robots.txt`, and `sitemap.xml` — sit at the root. Every one of them is a case of the `StaticFile` enumeration, which is what the pages derive their URLs from.
|
`Resources/Static` holds the site's stylesheets and scripts under `css/` and `js/`, paired by name: `shared.*` is loaded by every page, alongside a per-page `index.*` and `not-found.*`. The remaining files — the icons, `site.webmanifest`, `robots.txt`, and `sitemap.xml` — sit at the root. Every one of them is a case of the `StaticFile` enumeration, which is what the pages derive their URLs from.
|
||||||
|
|
||||||
The image build optimizes the files under `Resources/Static` in its `assets` stage, in place, with pinned optimizer versions so asset output is reproducible for a given Dockerfile commit:
|
The image build optimizes the files under `Resources/Static` in its `assets` stage, in place, with pinned optimizer versions — and on a base image pinned by digest, not just by tag — so asset output is reproducible for a given Dockerfile commit:
|
||||||
- CSS and JS are minified with [esbuild](https://esbuild.github.io) (every file in `css/` and `js/`).
|
- CSS and JS are minified with [esbuild](https://esbuild.github.io) (every file in `css/` and `js/`).
|
||||||
- PNG images are losslessly recompressed with [oxipng](https://github.com/oxipng/oxipng), recursively — the output is pixel-identical, only encoded smaller.
|
- PNG images are losslessly recompressed with [oxipng](https://github.com/oxipng/oxipng), recursively — the output is pixel-identical, only encoded smaller.
|
||||||
- SVGs are minified with [svgo](https://github.com/svg/svgo), recursively.
|
- SVGs are minified with [svgo](https://github.com/svg/svgo), recursively.
|
||||||
@@ -321,7 +334,8 @@ The Makefile and Compose files read these from `.env` (or the environment). Prov
|
|||||||
| `HOST_OWNER` | Registry namespace / owner. |
|
| `HOST_OWNER` | Registry namespace / owner. |
|
||||||
| `HOST_USER`, `HOST_PASSWORD` | Registry credentials for `make img-release`. |
|
| `HOST_USER`, `HOST_PASSWORD` | Registry credentials for `make img-release`. |
|
||||||
| `IMAGE_NAME`, `IMAGE_TAG` | Image name and tag. `IMAGE_TAG` is the fallback for `make img-release`'s `version=` argument. |
|
| `IMAGE_NAME`, `IMAGE_TAG` | Image name and tag. `IMAGE_TAG` is the fallback for `make img-release`'s `version=` argument. |
|
||||||
| `IMAGE_PLATFORM` | Build platform for `make img-release` (e.g. `linux/amd64`). `make img-check` pins `linux/amd64` regardless. |
|
| `IMAGE_PLATFORM` | Deployment architecture (default `linux/amd64`). Drives `make img-check`, `make img-release`, and the `platform:` the production Compose file runs the pulled image with — keep it matched to the deployment host. |
|
||||||
|
| `BUILD_PLATFORM` | Architecture of the *local* development build only (default `linux/arm64`); set it to `linux/amd64` on an Intel Mac or an amd64 Linux box. Never used by the production Compose file. |
|
||||||
| `HOST_PORT` | Host port mapped to the container's `8080` (default `8080`). |
|
| `HOST_PORT` | Host port mapped to the container's `8080` (default `8080`). |
|
||||||
| `LOG_LEVEL` | Runtime log level (default `info`). |
|
| `LOG_LEVEL` | Runtime log level (default `info`). |
|
||||||
| `HTTP_SERVER_NAME` | Runtime server name (default `SiteWebsite`). |
|
| `HTTP_SERVER_NAME` | Runtime server name (default `SiteWebsite`). |
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
services:
|
services:
|
||||||
website:
|
website:
|
||||||
image: ${IMAGE_NAME}:${IMAGE_TAG:-latest}
|
image: ${IMAGE_NAME}:${IMAGE_TAG:-latest}
|
||||||
platform: linux/arm64
|
# The local build targets the development machine, not the deployment: this cancels the base file's
|
||||||
|
# ${IMAGE_PLATFORM}. Set BUILD_PLATFORM=linux/amd64 in .env when developing on an Intel Mac or an amd64 Linux box.
|
||||||
|
platform: ${BUILD_PLATFORM:-linux/arm64}
|
||||||
build:
|
build:
|
||||||
context: ../..
|
context: ../..
|
||||||
dockerfile: Services/Website/Dockerfile
|
dockerfile: Services/Website/Dockerfile
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ name: site-platform
|
|||||||
services:
|
services:
|
||||||
website:
|
website:
|
||||||
image: ${HOST_CONTAINER}/${HOST_OWNER}/${IMAGE_NAME}:${IMAGE_TAG:-latest}
|
image: ${HOST_CONTAINER}/${HOST_OWNER}/${IMAGE_NAME}:${IMAGE_TAG:-latest}
|
||||||
platform: linux/amd64
|
platform: ${IMAGE_PLATFORM:-linux/amd64}
|
||||||
container_name: ${HOST_OWNER}-${IMAGE_NAME}
|
container_name: ${HOST_OWNER}-${IMAGE_NAME}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
Reference in New Issue
Block a user