Add Docker build and compose setup

Three-stage build: bun compiles the frontend, cargo embeds it into the
release binary, and the runtime stage carries only that binary on
debian:trixie-slim as an unprivileged user.

compose.yml pins DIARY_BIND and DIARY_DATA_DIR over whatever .env says,
keeps state in a named volume, publishes on localhost only and probes the
port with bash so the image needs no curl. compose.override.yml.example
shows the traefik overlay for the personal host.
This commit is contained in:
nvrl committed 2026-09-05 05:51:29 +02:00
1 parent 4002a35305
commit e9f44b8809
6 files changed
+120

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
.git
.env
target
data
web/dist
web/node_modules
README.md
Makefile
Dockerfile
.dockerignore
compose*.yml
+3
View File
@@ -10,3 +10,6 @@
# secrets # secrets
.env .env
# host-specific compose overlay
compose.override.yml
+36
View File
@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1
# 1. Frontend. Built first so a Rust-only change does not re-run bun.
FROM oven/bun:1-alpine AS web
WORKDIR /web
COPY web/package.json web/bun.lock ./
RUN bun install --frozen-lockfile
COPY web/ ./
RUN bun run build
# 2. Backend. rust-embed bakes web/dist into the binary in release mode,
# so the result is a single self-contained executable.
FROM rust:1-slim-trixie AS build
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
COPY src/ ./src/
COPY migrations/ ./migrations/
COPY --from=web /web/dist/ ./web/dist/
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/app/target \
cargo build --release --locked \
&& cp target/release/narl-diary /usr/local/bin/narl-diary
# 3. Runtime. SQLite is compiled into the binary, so nothing else is needed.
FROM debian:trixie-slim
RUN useradd --system --uid 10001 --create-home --home-dir /home/diary diary \
&& mkdir -p /data \
&& chown diary:diary /data
COPY --from=build /usr/local/bin/narl-diary /usr/local/bin/narl-diary
USER diary
WORKDIR /home/diary
VOLUME ["/data"]
ENV DIARY_BIND=0.0.0.0:4242 \
DIARY_DATA_DIR=/data
EXPOSE 4242
ENTRYPOINT ["/usr/local/bin/narl-diary"]
+19
View File
@@ -92,3 +92,22 @@ Everything except the two share routes requires the session cookie.
Put a TLS-terminating reverse proxy in front of it, point it at `DIARY_BIND`, Put a TLS-terminating reverse proxy in front of it, point it at `DIARY_BIND`,
and set `DIARY_SECURE_COOKIE=1`. Nothing else is needed: no database server, no and set `DIARY_SECURE_COOKIE=1`. Nothing else is needed: no database server, no
runtime dependencies, no build tools on the host. runtime dependencies, no build tools on the host.
### Docker
```sh
cp .env.example .env # set DIARY_USER, DIARY_PASSWORD, DIARY_SECRET
docker compose up -d --build
```
The image builds the frontend with bun and the binary with cargo, then ships
only the binary on `debian:trixie-slim` (~86 MB), running as an unprivileged
user. `DIARY_BIND` and `DIARY_DATA_DIR` are forced in `compose.yml`, so the
values in `.env` do not have to change; everything else is read from `.env`.
Database and uploads live in the `diary-data` volume and survive recreation.
By default the port is published on `127.0.0.1:4242` only. To serve it through
traefik instead, copy `compose.override.yml.example` to `compose.override.yml`
and adjust the host — it joins the external `narl` network, drops the published
port, and routes to container port 4242. Set `DIARY_SECURE_COOKIE=1` in `.env`
once it is served over HTTPS.
+18
View File
@@ -0,0 +1,18 @@
# Copy to compose.override.yml on the host that runs traefik.
services:
diary:
ports: !override []
networks:
- narl
- internal_net
labels:
- "traefik.enable=true"
- "traefik.docker.network=narl"
- "traefik.http.routers.diary.rule=Host(`diary.narl.io`)"
- "traefik.http.routers.diary.entrypoints=websecure"
- "traefik.http.routers.diary.tls.certresolver=https_resolver"
- "traefik.http.services.diary.loadbalancer.server.port=4242"
networks:
narl:
external: true
+33
View File
@@ -0,0 +1,33 @@
services:
diary:
build: .
image: narl-diary:latest
restart: unless-stopped
env_file:
- .env
environment:
# These win over anything in .env: inside the container the app must
# listen on all interfaces and keep its state on the volume.
DIARY_BIND: 0.0.0.0:4242
DIARY_DATA_DIR: /data
volumes:
- diary-data:/data
networks:
- internal_net
ports:
# Localhost only. Drop this in the override when a reverse proxy
# reaches the container over its own network instead.
- "127.0.0.1:4242:4242"
healthcheck:
# No extra tooling in the image, so probe the port with bash directly.
test: ["CMD", "bash", "-c", "exec 3<>/dev/tcp/127.0.0.1/4242"]
interval: 30s
timeout: 3s
start_period: 5s
retries: 3
volumes:
diary-data:
networks:
internal_net: