Toast
ContributorPatterns

Docker Patterns

Docker Patterns

This guide covers how Docker builds work in Toast and how to avoid breaking them.

Overview

Toast has three Dockerfiles:

FileServicePurpose
DockerfileAPINode.js Hono server with database access
apps/admin/Dockerfile.adminAdminVite static build served via nginx
apps/docs/Dockerfile.docsDocsNext.js standalone build for documentation

All three are multi-stage builds optimized for production.

Node.js Version Policy

  • Toast requires Node.js 24.x (package.json engines)
  • CI, Docker images, and the devcontainer run Node.js 24
  • .nvmrc and .node-version are pinned to 24 (tool-specific format)

Why Docker Builds Can Break

The most common cause of Docker build failures is missing files or environment variables that pnpm needs during pnpm install.

API Build Scope

The API Dockerfile intentionally builds only the API workspace and its dependency graph:

RUN pnpm build --filter @toast/api...

Why this is scoped:

  1. Reduces Docker build time by skipping unrelated workspace builds
  2. Improves cache reuse by avoiding invalidation from unrelated package changes
  3. Keeps CI and local image builds aligned with API runtime needs

If the API image needs artifacts from packages outside this graph, either make those packages real dependencies of @toast/api or widen the filter in Dockerfile.

The docs app uses symlinks from apps/docs/content/code/ to the repo-root docs/ directory to include contributor documentation (patterns, decisions, drivers, reference). Docker COPY doesn't follow symlinks outside the build context, so the Dockerfile:

  1. Copies the repo-root docs/ directory into the container
  2. Replaces symlinks with real copies during the build stage
  3. Runs pnpm build --filter @toast/docs to produce Next.js standalone output

The .dockerignore re-includes apps/docs/**/*.md, apps/docs/**/*.mdx, and docs/**/*.md to ensure content files reach the build context despite the global *.md exclusion.

Additionally, fumadocs-mdx runs a postinstall script that compiles source.config.ts via esbuild. This file (and tsconfig.json) must be copied into the deps stage before pnpm install, or the postinstall fails with "The entry point source.config.ts cannot be marked as external".

The .npmrc Requirement

Toast uses TipTap Pro packages from a private registry. The .npmrc file configures this:

@tiptap-pro:registry=https://registry.tiptap.dev/
//registry.tiptap.dev/:_authToken=${TIPTAP_PRO_TOKEN}

For Docker builds to work:

  1. .npmrc must be copied into the image before pnpm install
  2. TIPTAP_PRO_TOKEN must be available during pnpm install

Both Dockerfiles use build args (Railway doesn't support BuildKit secret mounts):

# Copy .npmrc alongside package files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./

# Token passed via build-arg
ARG TIPTAP_PRO_TOKEN
RUN pnpm install --frozen-lockfile

Note: ARG values can appear in docker history but are NOT persisted in the final image metadata (unlike ENV). This is acceptable because Railway and ghcr.io images are private

CI Protection

The CI workflow builds both images and pushes them to GitHub Container Registry (ghcr.io) on every commit. This ensures:

  1. The same image tested in CI is what gets deployed
  2. Pre-built images are available for self-hosting
  3. Railway can pull images instead of building (faster deploys)

For the API image, CI uses the same Dockerfile build scope (pnpm build --filter @toast/api...) because it builds directly from Dockerfile.

Building Locally

# Required for both images
export TIPTAP_PRO_TOKEN=<your-token>

# Build API image
docker build \
  --build-arg TIPTAP_PRO_TOKEN="$TIPTAP_PRO_TOKEN" \
  -t toast-api .

# Build Admin image
docker build \
  --build-arg TIPTAP_PRO_TOKEN="$TIPTAP_PRO_TOKEN" \
  --build-arg VITE_API_URL="https://your-api-url.com" \
  -f apps/admin/Dockerfile.admin -t toast-admin .

# Build Docs image
docker build \
  --build-arg TIPTAP_PRO_TOKEN="$TIPTAP_PRO_TOKEN" \
  -f apps/docs/Dockerfile.docs -t toast-docs .

Railway Deployment

Railway automatically:

  • Detects Dockerfiles via railway.json config
  • Passes environment variables as build arguments
  • Runs PR preview deployments

Required Environment Variables

Both services need these variables set in Railway:

API Service:

VariablePurpose
TIPTAP_PRO_TOKENNPM registry auth (build time)
TIPTAP_COLLAB_SECRETJWT signing for collaboration tokens
TIPTAP_AI_SECRETJWT signing for AI tokens
VITE_TIPTAP_COLLAB_APP_IDReturned in collaboration token response
VITE_TIPTAP_AI_APP_IDReturned in AI token response
EMAIL_DRIVEREmail provider (required in production)
EMAIL_FROMDefault sender address (e.g., noreply@...)
MAILGUN_API_KEYMailgun API key (if using Mailgun)
MAILGUN_DOMAINMailgun sending domain (if using Mailgun)
MAILGUN_REGIONMailgun region: us or eu (optional)
STORAGE_DRIVERStorage backend (toast-driver-storage-s3)
S3_ENDPOINTS3-compatible endpoint URL
S3_ACCESS_KEY_IDS3 access key ID
S3_SECRET_ACCESS_KEYS3 secret access key
S3_BUCKETS3 bucket name
S3_REGIONS3 region (default: auto)
S3_PUBLIC_URLPublic URL for accessing uploaded files

Admin Service:

VariablePurpose
TIPTAP_PRO_TOKENNPM registry auth (build time)
VITE_API_URLAPI endpoint for frontend (build time)

Important: VITE_API_URL is baked into the static JavaScript bundle at build time. If you change it, you must rebuild the admin image - a restart won't pick up the change.

Docs Service:

VariablePurpose
TIPTAP_PRO_TOKENNPM registry auth (build time)

The docs service has no runtime environment variables. Content is statically built into the Next.js standalone output.

Seed Fixtures for Preview Environments

The API Dockerfile copies seed fixtures into the final image for preview/staging environments:

COPY --from=build /app/packages/db/dist/fixtures ./packages/db/dist/fixtures

These fixtures contain:

  • Compiled TypeScript profile loaders (load-profile.js, types.js)
  • JSON content files for each profile (profiles/default/content.json, profiles/development/content.json)

The seed script uses these at runtime when preDeployCommand runs pnpm db:seed on Railway. The fixtures are loaded relative to the compiled seed script's location via import.meta.url.

Note: The fixture files are copied during pnpm build in the @toast/db package via a copy-fixtures script that moves JSON profiles from src/fixtures/profiles to dist/fixtures/profiles.

Adding New Private Registries

If you add a dependency from another private registry:

  1. Add the registry config to .npmrc
  2. Add the corresponding ARG/ENV to both Dockerfiles (in the deps stage)
  3. Set the secret in:
    • GitHub Actions (for CI)
    • Railway staging environment
    • All existing Railway PR environments

Troubleshooting

"ERR_PNPM_FETCH_404" or "No authorization header"

The token isn't reaching pnpm. Check:

  1. Is .npmrc being copied? (COPY ... .npmrc ./)
  2. Is the ARG defined before the COPY? (ARG TIPTAP_PRO_TOKEN)
  3. Is ENV set from the ARG? (ENV TIPTAP_PRO_TOKEN=$TIPTAP_PRO_TOKEN)
  4. Is the build-arg being passed? (--build-arg TIPTAP_PRO_TOKEN=...)

"Lockfile is not up to date"

The pnpm-lock.yaml doesn't match package.json. Run pnpm install locally and commit the updated lockfile.

"Connect Timeout Error" during pnpm install

ConnectTimeoutError: Connect Timeout Error (attempted address: registry.npmjs.org:443, timeout: 10000ms)

This is a transient network failure - Railway's build VM couldn't reach the npm registry. The Dockerfiles pre-install pnpm via corepack prepare to mitigate this, but if the error occurs during pnpm install (fetching actual packages), it's a temporary infrastructure issue. Retry the deployment.

Updating pnpm version

When you update packageManager in package.json, you must also update all three Dockerfiles:

# In Dockerfile, apps/admin/Dockerfile.admin, and apps/docs/Dockerfile.docs
RUN corepack enable && corepack prepare pnpm@10.28.2 --activate  # ← Update version here

This ensures pnpm is pre-installed in the base image layer, which:

  1. Allows Docker to cache the layer (faster subsequent builds)
  2. Avoids on-demand downloads that can timeout during npm registry issues

Editor shows "Connecting..." forever

The TipTap collaboration isn't configured. Check:

  1. Are VITE_TIPTAP_COLLAB_APP_ID and TIPTAP_COLLAB_SECRET set on the API service?
  2. Is /api/capabilities returning "collaboration": { "enabled": true, "config": { "appId": "..." } }?
  3. If you changed API environment variables, redeploy/restart the API service so runtime config is reloaded.

Pre-built Images

CI automatically builds and pushes images to GitHub Container Registry on every commit.

Authentication: Images are currently private. To pull, authenticate first:

echo $GITHUB_TOKEN | docker login ghcr.io -u USERNAME --password-stdin
# Pull the latest images
docker pull ghcr.io/tryghost/toast-api:main
docker pull ghcr.io/tryghost/toast-admin:main
docker pull ghcr.io/tryghost/toast-docs:main

Image Tags

TagDescription
mainLatest from main branch (mutable)
sha-<commit>Specific commit (immutable)
pr-<number>PR preview build

Self-Hosting

For self-hosted deployments:

Note: Pre-built admin images have VITE_API_URL baked in at CI build time:

  • VITE_API_URL - defaults to empty (uses relative URLs)

For custom values, build your own admin image (see Building Locally).

Security: Passing secrets via -e flags exposes them in ps output and shell history. For production, use:

  • Env file: docker run --env-file .env ... (secure file permissions with chmod 600)
  • Docker secrets: For Docker Swarm/Kubernetes deployments
  • Orchestrator secrets: Railway, Fly.io, etc. handle this automatically
# Run API (example using env file)
docker run -p 3000:3000 --env-file .env ghcr.io/tryghost/toast-api:main

# Required in .env:
# DATABASE_URL=postgresql://...
# BETTER_AUTH_SECRET=...
# TIPTAP_COLLAB_SECRET=...
# TIPTAP_AI_SECRET=...
# VITE_TIPTAP_COLLAB_APP_ID=...
# VITE_TIPTAP_AI_APP_ID=...

# Run Admin (pre-built images use CI's baked-in VITE_API_URL value)
docker run -p 8080:80 ghcr.io/tryghost/toast-admin:main

# For custom API URL, build your own admin image:
docker build -f apps/admin/Dockerfile.admin \
  --build-arg VITE_API_URL=https://your-api.example.com \
  --build-arg TIPTAP_PRO_TOKEN=$TIPTAP_PRO_TOKEN \
  -t my-toast-admin .

Railway Integration

Railway can be configured to pull pre-built images instead of building:

  1. Change service source from "GitHub Repo" to "Docker Image"
  2. Set image URL: ghcr.io/tryghost/toast-api:main (or toast-admin, toast-docs)
  3. Add registry credentials if images are private

This eliminates build time and avoids TipTap registry rate limiting.

Admin image note: Pre-built admin images have VITE_API_URL baked at build time. If your Railway environment needs a different API URL, keep the admin service building from "GitHub Repo" or set up a custom build pipeline.

Future Enhancements

  • Multi-architecture builds: Support both amd64 and arm64 (Apple Silicon)
  • docker-compose.yml: Simple docker compose up for local self-hosting
  • Versioned releases: Semantic version tags (v1.0.0) for stable releases

On this page