Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/syhily/yufan.me/llms.txt

Use this file to discover all available pages before exploring further.

yufan.me ships a self-contained Docker image built on Node 25 Alpine. A two-stage build compiles the React Router application and assembles a lean runtime image that includes only production dependencies, the compiled build/ output, and the Drizzle migration files. No separate asset upload step is required — generated Vite assets are bundled directly into the image.

Dockerfile

The full Dockerfile is included in the repository root:
Dockerfile
FROM node:25-alpine AS build
WORKDIR /app
COPY . .
RUN --mount=type=cache,target=/root/.npm \
    npm ci
RUN NODE_ENV=production npm run build

FROM node:25-alpine AS runtime
WORKDIR /app
ENV NPM_CONFIG_LEGACY_PEER_DEPS=true
RUN apk add --no-cache postgresql-client
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --omit=dev
COPY --from=build /app/build ./build
COPY --from=build /app/drizzle ./drizzle
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=4321
EXPOSE 4321
CMD ["npm", "run", "start"]
The build stage runs npm run build (react-router build). The runtime stage copies only the compiled output and the drizzle/ migration directory — leaving dev dependencies and source files behind. The postgresql-client package is included so migration tooling can connect to Postgres from within the container.

Deployment steps

1

Build the image

Run the following command from the repository root. BuildKit is recommended for cache-mount support:
docker build -t yufan.me .
The build stage runs react-router build and produces build/server/index.js (the Hono/Node entry point) and static assets under build/client/.
2

Prepare environment variables

Create a .env file with the required variables before starting the container. Three variables are mandatory:
.env
# Database
DATABASE_URL=postgres://user:password@db-host:5432/yufanme

# Cache / sessions
REDIS_URL=redis://redis-host:6379

# Session cookie signing — use a high-entropy random string
SESSION_SECRET=replace-with-a-long-random-secret
Optional variables:
.env
# Server
HOST=0.0.0.0
PORT=4321

# Geo-enriched analytics (download binary from MaxMind)
MAXMIND_DB_PATH=./data/maxmind/GeoLite2-City.mmdb

# Set to true to include admin visits in analytics dashboards
ANALYTICS_TRACK_ADMIN=false

# Log verbosity
LOG_LEVEL=info
3

Apply database migrations

The drizzle/ directory is copied into the runtime image. Migrations must be applied against your Postgres database before the server starts for the first time, and again after any upgrade that adds new migration files.Run migrations from within a temporary container using drizzle-kit migrate:
docker run --rm --env-file .env yufan.me \
  npx drizzle-kit migrate
This executes against the DATABASE_URL in your .env file. See Database setup and migrations for a detailed breakdown of each migration.
4

Start the container

Pass your .env file and map the application port:
docker run -d \
  --name yufan.me \
  -p 4321:4321 \
  --env-file .env \
  yufan.me
The server starts with npm run start which runs node ./build/server/index.js. On first boot, every request redirects to /admin/setup until an admin account is created. Stage 2 at /admin/setup/settings then seeds the 14 settings rows atomically.
The default listen port is 4321. Override it by setting PORT in your environment. The HOST variable defaults to 0.0.0.0, which is required for the container to accept external connections.
Generated Vite assets are not uploaded to S3 by the build. They are bundled directly into the Docker image. Object storage (S3 or S3-compatible) is reserved exclusively for user-uploaded media such as images and is opt-in via the blog.assets settings section in the admin console.

Docker Compose example

The following docker-compose.yml starts yufan.me together with Postgres and Redis. Adjust credentials and volume paths for your environment.
docker-compose.yml
services:
  db:
    image: pgvector/pgvector:pg17
    restart: unless-stopped
    environment:
      POSTGRES_USER: yufan
      POSTGRES_PASSWORD: changeme
      POSTGRES_DB: yufanme
    volumes:
      - db_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U yufan -d yufanme"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5

  app:
    image: yufan.me
    build: .
    restart: unless-stopped
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_healthy
    ports:
      - "4321:4321"
    environment:
      DATABASE_URL: postgres://yufan:changeme@db:5432/yufanme
      REDIS_URL: redis://redis:6379
      SESSION_SECRET: replace-with-a-long-random-secret
      NODE_ENV: production

volumes:
  db_data:
  redis_data:
The pgvector/pgvector:pg17 image includes the pgvector extension pre-installed, which enables embedding-based search. Using a standard postgres:17 image works but falls back to SQL LIKE search. See Database setup and migrations for details.

Build docs developers (and LLMs) love