When I decided to deploy AFFiNE in my self-hosted environment, I quickly ran into a common problem: the official Docker images aren’t always updated when new releases drop. Looking at the official container registry, I found it was still stuck on version 0.22 while the latest release was already at 0.23.

This inconsistency, combined with my need for custom AI model support, led me to create my own build system. What started as a necessity to get current versions became a comprehensive Docker build solution that handles version management, custom AI models, and production optimizations.

Why Build Your Own?

There are several compelling reasons to maintain your own AFFiNE Docker builds:

  1. Version Control: Official images often lag behind releases
  2. Customization: Add your own AI models, configurations, and optimizations
  3. Security: Full control over what goes into your production images
  4. Reliability: No dependency on external update schedules
  5. Transparency: Complete visibility into the build process

TLDR - Sources are at the bottom of this article.

The Evolution of the Build System

After successfully getting AFFiNE’s AI features working in my self-hosted environment, I decided to take it a step further and create a proper Docker build system. This project evolved from a simple workaround into a comprehensive build solution.

Phase 1: Basic AI Fix

Initially, I just needed to replace the problematic default AI model:

find . -name "*.ts" -type f -exec sed -i 's/claude-sonnet-4@20250514/gpt-4o/g' {} \;

Phase 2: Systematic Approach

As I refined the process, I realized I needed:

  • Automated version management
  • Configurable AI models
  • Proper build optimization
  • Production-ready output
  • Always current versions without waiting for official updates

The Build Script Architecture

Core Configuration

The build system uses environment variables for flexibility:

BUILD_TYPE="${BUILD_TYPE:-dev}"
GIT_TAG="${GIT_TAG:-canary}"
GIT_REPO="${GIT_REPO:-https://github.com/toeverything/AFFiNE.git}"
REGISTRY="${REGISTRY:-registry.private.xyz}"
IMAGE_NAME="${IMAGE_NAME:-affine}"

This allows for easy customization without modifying the script:

BUILD_TYPE=stable GIT_TAG=v0.23.0 ./build-affine-graph.sh

Smart Tagging System

The script generates meaningful tags automatically:

  • Timestamped tags: affine:stable-v0.23.0-20250116-164500
  • Latest tags: affine:stable-latest
  • Build metadata preserved in Docker labels

Logging and Monitoring

I implemented a comprehensive logging system with:

  • Color-coded output for different message types
  • Execution time tracking for each build phase
  • Success/failure indicators
  • Build verification steps
time_cmd() {
    local start_time=$(date +%s)
    local cmd_name="$1"
    shift
    log_info "Starting: $cmd_name"

    if "$@"; then
        local end_time=$(date +%s)
        local duration=$((end_time - start_time))
        log_success "Completed: $cmd_name (${duration}s)"
        return 0
    else
        local end_time=$(date +%s)
        local duration=$((end_time - start_time))
        log_error "Failed: $cmd_name (${duration}s)"
        return 1
    fi
}

The Dockerfile Deep Dive

Multi-Stage Build Optimization

The Dockerfile uses a sophisticated multi-stage approach:

FROM node:22-bookworm-slim AS builder
# ... build logic ...

FROM node:22-bookworm-slim AS production
# ... production setup ...

Key Build Arguments

The system supports multiple configuration options:

  • GIT_TAG: Which AFFiNE version to build
  • BUILD_TYPE: stable, canary, beta, internal
  • AI_MODEL: Default AI model to use
  • CUSTOM_MODELS: Additional models to add

Automated Version Management

One of the most useful features automatically updates all package.json files:

RUN find . -name "package.json" -type f -exec sed -i 's/"version": "[^"]*"/"version": "'${GIT_TAG#v}'"/' {} \;

This ensures version consistency across all 123+ packages in the monorepo.

AI Model Integration

Default Model Replacement

The system automatically replaces the problematic default:

RUN find . -name "*.ts" -type f -exec sed -i 's/claude-sonnet-4@20250514/'"$AI_MODEL"'/g' {} \;

Custom Model Support (Experimental)

I experimented with adding custom models dynamically:

ARG CUSTOM_MODELS='deepseek-r1'
# Script to add models to OpenAI provider

This proved complex due to Docker’s multiline limitations, so I commented it out for now, but the foundation is there.

Build Process Optimization

Dependency Management Strategy

The build process carefully manages dependencies:

  1. Full install for initial setup
  2. Native component builds with focused workspaces
  3. Full reinstall after native builds (crucial for frontend)
  4. Production-focused final install

Build Order Matters

Through trial and error, I discovered the optimal build sequence:

  1. Native components (@affine/server-native)
  2. Core dependencies (@affine/reader)
  3. Server components
  4. Frontend applications (web, admin, mobile)
  5. Database setup (Prisma)

Frontend Build Challenges

The frontend builds were particularly tricky. The key insight was that after workspace focusing, you need to reinstall all dependencies before building frontend components:

# Reinstall ALL dependencies for frontend builds
RUN yarn install --immutable --inline-builds

# Build frontend components (now all dependencies should be available)
RUN yarn affine @affine/web build
RUN yarn affine @affine/admin build
RUN yarn affine @affine/mobile build

Production Optimizations

Runtime Environment

The production image includes:

  • jemalloc for better memory management
  • Minimal base image (node:22-bookworm-slim)
  • Health checks for container orchestration
  • Proper signal handling
# Enable jemalloc for better memory performance
ENV LD_PRELOAD=libjemalloc.so.2

# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
    CMD curl -f http://localhost:3010/api/health || exit 1

Security Considerations

  • Non-root user execution
  • Minimal system dependencies
  • Clean package manager caches
  • Secure file permissions

Build Verification

Automated Testing

The script includes verification steps:

# Verify build artifacts exist
RUN ls -la packages/backend/server/dist/ && \
    ls -la packages/frontend/apps/web/dist/ && \
    ls -la packages/frontend/admin/dist/ && \
    ls -la packages/frontend/apps/mobile/dist/

Image Testing

Post-build verification includes:

  • Image size reporting
  • Label verification
  • Basic functionality tests
  • Node.js version checks
# Test the image
log_info "Testing Docker image..."
if docker run --rm "$IMAGE_TAG" node --version; then
    log_success "Docker image test passed"
else
    log_warning "Docker image test failed"
fi

Usage Examples

Basic Build

./build-affine-graph.sh

Production Build with Custom Model

BUILD_TYPE=stable GIT_TAG=v0.23.0 AI_MODEL=gpt-4o ./build-affine-graph.sh

Custom Registry

REGISTRY=your-registry.com BUILD_TYPE=stable ./build-affine-graph.sh

Build Latest Release

BUILD_TYPE=stable GIT_TAG=v0.23.0 ./build-affine-graph.sh

Lessons Learned

Docker BuildKit is Essential

Using export DOCKER_BUILDKIT=1 significantly improved build performance and provided better caching.

Build Context Matters

The build process is sensitive to the order of operations, especially around dependency installation and workspace focusing.

Version Consistency is Critical

Updating all package.json versions to match the git tag prevents subtle issues in the monorepo.

Logging Saves Time

Comprehensive logging with timing information makes debugging build failures much easier.

Official Images Lag Behind

Having your own build system means you can deploy the latest features immediately without waiting for official Docker updates.

Current Status and Future Plans

What Works

  • ✅ Stable builds with OpenAI, Anthropic, and Gemini
  • ✅ Automated version management
  • ✅ Production-ready Docker images
  • ✅ Build verification and testing
  • ✅ Flexible configuration system
  • ✅ Always current with latest AFFiNE releases

What’s Next

  • 🔄 Better Ollama integration (API endpoint compatibility)
  • 🔄 Dynamic custom model addition
  • 🔄 Multi-architecture builds
  • 🔄 Build caching optimizations
  • 🔄 CI/CD pipeline integration

The Complete Build System

The entire system consists of:

  • build-affine-graph.sh: Main build orchestration
  • Dockerfile: Multi-stage build configuration
  • Environment variable configuration
  • Automated testing and verification

This project transformed from a quick AI fix into a robust build system that could easily be adapted for CI/CD pipelines or team use, while ensuring I’m never stuck waiting for official Docker image updates.

Conclusion

Building a production-ready AFFiNE Docker image turned out to be more complex than expected, but the result is a flexible, maintainable system that handles version management, AI model configuration, and production optimizations automatically.

The key was treating it as a proper build system rather than a simple Docker wrapper, which led to much better reliability and easier maintenance. Most importantly, it ensures I can always deploy the latest AFFiNE features without depending on the official Docker image release schedule.

If you’re running AFFiNE in production or considering it, this build system provides a solid foundation that you can customize for your specific needs while maintaining full control over your deployment timeline.


Feel free to adapt it for your own AFFiNE deployments!

The Dockerfile:

# Build stage - Complete AFFiNE build process
FROM node:22-bookworm-slim AS builder

# Build arguments
ARG GIT_REPO=https://github.com/toeverything/AFFiNE.git
ARG GIT_TAG=canary
ARG BUILD_TYPE=stable
ARG AI_MODEL=claude-sonnet-4-20250514
ARG CUSTOM_MODELS='deepseek-r1'

# Install system dependencies
RUN apt-get update && apt-get install -y \
    git \
    python3 \
    python3-pip \
    build-essential \
    libssl-dev \
    pkg-config \
    curl \
    jq \
    unzip \
    && rm -rf /var/lib/apt/lists/*

# Install Rust
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
ENV PATH="/root/.cargo/bin:${PATH}"

# Install Bun
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:$PATH"

WORKDIR /affine

# Clone repository
RUN git clone --depth 1 --branch ${GIT_TAG} ${GIT_REPO} .

# Update all package.json versions to match git tag
RUN find . -name "package.json" -type f -exec sed -i 's/"version": "[^"]*"/"version": "'${GIT_TAG#v}'"/' {} \;

# Update all ts files to replace default AI model
RUN find . -name "*.ts" -type f -exec sed -i 's/claude-sonnet-4@20250514/'"$AI_MODEL"'/g' {} \;

# Add custom AI models to OpenAI provider

RUN echo '#!/bin/bash' > /tmp/add_models.sh && \
    echo 'IFS="," read -ra MODELS <<< "$1"' >> /tmp/add_models.sh && \
    echo 'for model in "${MODELS[@]}"; do' >> /tmp/add_models.sh && \
    echo '  echo "Adding model: $model"' >> /tmp/add_models.sh && \
    echo '  sed -i "/\/\/ Text to Text models/a\\' >> /tmp/add_models.sh && \
    echo '    {\\' >> /tmp/add_models.sh && \
    echo '      id: \"$model\",\\' >> /tmp/add_models.sh && \
    echo '      capabilities: [\\' >> /tmp/add_models.sh && \
    echo '        {\\' >> /tmp/add_models.sh && \
    echo '          input: [ModelInputType.Text, ModelInputType.Image],\\' >> /tmp/add_models.sh && \
    echo '          output: [ModelOutputType.Text, ModelOutputType.Object],\\' >> /tmp/add_models.sh && \
    echo '        },\\' >> /tmp/add_models.sh && \
    echo '      ],\\' >> /tmp/add_models.sh && \
    echo '    }," packages/backend/server/src/plugins/copilot/providers/openai.ts' >> /tmp/add_models.sh && \
    echo 'done' >> /tmp/add_models.sh && \
    chmod +x /tmp/add_models.sh && \
    /tmp/add_models.sh "$CUSTOM_MODELS"

# Setup Node.js
RUN corepack enable

# Configure yarn
RUN yarn config set nmMode classic || true
RUN yarn config set enableScripts true

# Set environment variables
ENV HUSKY=0
ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1
ENV ELECTRON_SKIP_BINARY_DOWNLOAD=1
ENV SENTRYCLI_SKIP_DOWNLOAD=1

# Install ALL dependencies (don't use workspaces focus yet)
RUN yarn install --immutable --inline-builds

# Fix permissions
RUN chmod +x node_modules/.bin/* || true

# Build native components first
RUN yarn workspaces focus @affine/server-native
RUN yarn workspace @affine/server-native build
RUN cp ./packages/backend/native/server-native.node ./packages/backend/native/server-native.x64.node
RUN cp ./packages/backend/native/server-native.node ./packages/backend/native/server-native.arm64.node
RUN cp ./packages/backend/native/server-native.node ./packages/backend/native/server-native.armv7.node

# IMPORTANT: Reinstall ALL dependencies after native build
RUN yarn install --immutable --inline-builds

# Build ALL components in the right order
ENV BUILD_TYPE=${BUILD_TYPE}

# Build core dependencies first
RUN yarn workspace @affine/reader build

# Build server
RUN yarn workspaces focus @affine/server @types/affine__env
RUN yarn workspace @affine/server build

# Reinstall ALL dependencies for frontend builds
RUN yarn install --immutable --inline-builds

# Build frontend components (now all dependencies should be available)
RUN yarn affine @affine/web build
RUN yarn affine @affine/admin build
RUN yarn affine @affine/mobile build

# Generate Prisma client
RUN yarn config set --json supportedArchitectures.cpu '["x64", "arm64", "arm"]'
RUN yarn config set --json supportedArchitectures.libc '["glibc"]'
RUN yarn workspaces focus @affine/server --production
RUN yarn workspace @affine/server prisma generate

# Move node_modules
RUN mv ./node_modules ./packages/backend/server

# Verify build artifacts
RUN ls -la packages/backend/server/dist/ && \
    ls -la packages/frontend/apps/web/dist/ && \
    ls -la packages/frontend/admin/dist/ && \
    ls -la packages/frontend/apps/mobile/dist/

# Production stage
FROM node:22-bookworm-slim AS production

ARG GIT_TAG
ARG BUILD_TYPE
ARG BUILD_DATE
ARG AI_MODEL

RUN apt-get update && \
    apt-get install -y --no-install-recommends openssl libjemalloc2 && \
    rm -rf /var/lib/apt/lists/*

COPY --from=builder /affine/packages/backend/server /app
COPY --from=builder /affine/packages/frontend/apps/web/dist /app/static
COPY --from=builder /affine/packages/frontend/admin/dist /app/static/admin
COPY --from=builder /affine/packages/frontend/apps/mobile/dist /app/static/mobile

WORKDIR /app

ENV LD_PRELOAD=libjemalloc.so.2

LABEL git.tag=${GIT_TAG}
LABEL build.type=${BUILD_TYPE}
LABEL build.date=${BUILD_DATE}
LABEL ai.model=%{AI_MODEL}

EXPOSE 3010

HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
    CMD curl -f http://localhost:3010/api/health || exit 1

CMD ["node", "./dist/main.js"]

The complete bash script.

#!/bin/bash
set -euo pipefail

# Configuration
BUILD_TYPE="${BUILD_TYPE:-dev}"
GIT_TAG="${GIT_TAG:-canary}"
GIT_REPO="${GIT_REPO:-https://github.com/toeverything/AFFiNE.git}"
REGISTRY="${REGISTRY:-registry.private.xyz}"
IMAGE_NAME="${IMAGE_NAME:-affine}"
PUSH_TO_REGISTRY="${PUSH_TO_REGISTRY:-false}"

# Generate build date
BUILD_DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

# Generate image tag
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
IMAGE_TAG="${REGISTRY}/${IMAGE_NAME}:${BUILD_TYPE}-${GIT_TAG}-${TIMESTAMP}"
LATEST_TAG="${REGISTRY}/${IMAGE_NAME}:${BUILD_TYPE}-latest"

# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'

log_info() { echo -e "${BLUE}ℹ️  $1${NC}"; }
log_success() { echo -e "${GREEN}$1${NC}"; }
log_warning() { echo -e "${YELLOW}⚠️  $1${NC}"; }
log_error() { echo -e "${RED}$1${NC}"; }

time_cmd() {
    local start_time=$(date +%s)
    local cmd_name="$1"
    shift
    log_info "Starting: $cmd_name"

    if "$@"; then
        local end_time=$(date +%s)
        local duration=$((end_time - start_time))
        log_success "Completed: $cmd_name (${duration}s)"
        return 0
    else
        local end_time=$(date +%s)
        local duration=$((end_time - start_time))
        log_error "Failed: $cmd_name (${duration}s)"
        return 1
    fi
}

# Display build info
log_info "🐳 Building AFFiNE Graph Image"
log_info "Repository: $GIT_REPO"
log_info "Git tag: $GIT_TAG"
log_info "Build type: $BUILD_TYPE"
log_info "Build date: $BUILD_DATE"
log_info "Image tag: $IMAGE_TAG"
log_info "Latest tag: $LATEST_TAG"

# Check Docker
if ! command -v docker &> /dev/null; then
    log_error "Docker is not installed"
    exit 1
fi

# Clean up
log_info "Cleaning up old images..."
docker image prune -f || true

# Build with BuildKit for better performance
export DOCKER_BUILDKIT=1

# Build the image
time_cmd "Build AFFiNE Graph Image" \
    docker build \
    --tag "$IMAGE_TAG" \
    --tag "$LATEST_TAG" \
    --build-arg GIT_REPO="$GIT_REPO" \
    --build-arg GIT_TAG="$GIT_TAG" \
    --build-arg BUILD_TYPE="$BUILD_TYPE" \
    --build-arg BUILD_DATE="$BUILD_DATE" \
    --target production \
    --no-cache \
    .

# Verify build
log_info "Verifying Docker image..."
if docker image inspect "$IMAGE_TAG" &>/dev/null; then
    log_success "Docker image built successfully"

    # Show image size
    size=$(docker image inspect "$IMAGE_TAG" --format='{{.Size}}' | numfmt --to=iec)
    log_info "Image size: $size"

    # Show build info from labels
    log_info "Build info:"
    docker image inspect "$IMAGE_TAG" --format='{{range $k,$v := .Config.Labels}}{{$k}}: {{$v}}{{"\n"}}{{end}}' | grep -E "(git\.|build\.)" || true

else
    log_error "Docker image verification failed"
    exit 1
fi

# Test the image
log_info "Testing Docker image..."
if docker run --rm "$IMAGE_TAG" node --version; then
    log_success "Docker image test passed"
else
    log_warning "Docker image test failed"
fi