Skip to main content
Campus uses a multi-stage Dockerfile to create an optimized production image that bundles SvelteKit, PocketBase, and Caddy in a single container.

Dockerfile Overview

The Dockerfile (located at ~/workspace/source/Dockerfile) uses a three-stage build process:
  1. Stage 1: Download PocketBase binary
  2. Stage 2: Build SvelteKit application
  3. Stage 3: Assemble production runtime

Stage 1: PocketBase Binary

FROM alpine:3 AS pocketbase

ARG TARGETARCH=amd64
ARG POCKETBASE_VERSION=0.34.0

RUN apk add --no-cache ca-certificates unzip wget \
    && wget -q https://github.com/pocketbase/pocketbase/releases/download/v${POCKETBASE_VERSION}/pocketbase_${POCKETBASE_VERSION}_linux_${TARGETARCH}.zip \
    && unzip pocketbase_${POCKETBASE_VERSION}_linux_${TARGETARCH}.zip -d /tmp \
    && chmod +x /tmp/pocketbase
This stage:
  • Uses Alpine Linux for minimal size
  • Downloads the specified PocketBase version from GitHub releases
  • Supports multi-architecture builds via TARGETARCH argument
  • Extracts and prepares the PocketBase binary

Stage 2: SvelteKit Build

FROM node:22-alpine AS builder

WORKDIR /app

# Install dependencies first (better caching)
COPY package*.json ./
RUN npm install

# Copy source and build
COPY . .
RUN npm run build
This stage:
  • Uses Node.js 22 on Alpine Linux
  • Installs dependencies separately for better Docker layer caching
  • Builds the SvelteKit application for production
  • The build is configured to use same-origin for browser requests (proxied by Caddy)
The PocketBase URL is determined at runtime. Browser requests use same-origin (Caddy proxies /api/*), while server-side requests can use INTERNAL_POCKETBASE_URL if set.

Stage 3: Production Runtime

FROM node:22-alpine

WORKDIR /app

# Install Caddy and tini for process management
RUN apk add --no-cache ca-certificates tini caddy

# Copy PocketBase binary
COPY --from=pocketbase /tmp/pocketbase /usr/local/bin/pocketbase

# Copy built application and production dependencies
COPY --from=builder /app/build ./build
COPY --from=builder /app/package*.json ./
RUN npm install --omit=dev && npm cache clean --force

# Copy PocketBase configuration
COPY pb_hooks ./pb_hooks
COPY pb_migrations ./pb_migrations

# Copy Caddy configuration and entrypoint
COPY Caddyfile /etc/caddy/Caddyfile
COPY docker-entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# Create data directory
RUN mkdir -p /pb_data

# Environment
ENV NODE_ENV=production

EXPOSE 8080

# Use tini for proper signal handling of multiple processes
ENTRYPOINT ["/sbin/tini", "-g", "--"]
CMD ["/entrypoint.sh"]
This stage:
  • Installs Caddy (reverse proxy) and tini (process manager)
  • Copies the PocketBase binary from stage 1
  • Copies the built SvelteKit app from stage 2
  • Installs only production Node.js dependencies
  • Includes PocketBase hooks and migrations
  • Sets up the entrypoint script
  • Exposes port 8080 (configurable via PORT env var)

Entrypoint Script

The docker-entrypoint.sh script starts all three services:
#!/bin/sh
set -e

# Railway provides PORT env var - Caddy will listen on it
RAILWAY_PORT="${PORT:-8080}"

echo "Starting PocketBase on port 8090..."
/usr/local/bin/pocketbase serve \
    --http="0.0.0.0:8090" \
    --dir=/pb_data \
    --hooksDir=/app/pb_hooks \
    --migrationsDir=/app/pb_migrations &

echo "Starting SvelteKit on port 3000..."
PORT=3000 node /app/build/index.js &

echo "Starting Caddy on port ${RAILWAY_PORT}..."
PORT="${RAILWAY_PORT}" exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
The script:
  1. Starts PocketBase on internal port 8090
  2. Starts SvelteKit on internal port 3000
  3. Starts Caddy on the external port (from PORT env var, defaults to 8080)
The entrypoint uses tini for proper signal handling. This ensures all processes shut down gracefully when the container stops.

Caddy Configuration

The Caddyfile configures request routing:
:{$PORT:8080} {
    # PocketBase native API endpoints
    handle /api/collections/* {
        reverse_proxy 127.0.0.1:8090
    }
    handle /api/realtime {
        reverse_proxy 127.0.0.1:8090
    }
    handle /api/files/* {
        reverse_proxy 127.0.0.1:8090
    }
    handle /api/admins/* {
        reverse_proxy 127.0.0.1:8090
    }
    handle /api/settings/* {
        reverse_proxy 127.0.0.1:8090
    }
    handle /api/logs/* {
        reverse_proxy 127.0.0.1:8090
    }
    handle /api/backups/* {
        reverse_proxy 127.0.0.1:8090
    }
    handle /api/health {
        reverse_proxy 127.0.0.1:8090
    }

    # PocketBase Admin UI
    handle /_/* {
        reverse_proxy 127.0.0.1:8090
    }

    # SvelteKit handles everything else
    handle {
        reverse_proxy 127.0.0.1:3000
    }

    encode gzip
    log {
        output stdout
    }
}

Building the Image

Basic Build

cd ~/workspace/source
docker build -t campus:latest .

Build with Specific PocketBase Version

docker build \
  --build-arg POCKETBASE_VERSION=0.34.0 \
  -t campus:latest \
  .

Multi-Architecture Build

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t campus:latest \
  .

Running the Container

Basic Usage

docker run -d \
  --name campus \
  -p 8080:8080 \
  -v $(pwd)/pb_data:/pb_data \
  campus:latest

With Environment Variables

docker run -d \
  --name campus \
  -p 8080:8080 \
  -v $(pwd)/pb_data:/pb_data \
  -e PORT=8080 \
  -e PUBLIC_ENABLE_ANALYTICS=false \
  -e MEDIA_MAX_IMAGE_SIZE_MB=10 \
  campus:latest

With Custom Port

docker run -d \
  --name campus \
  -p 3000:3000 \
  -v $(pwd)/pb_data:/pb_data \
  -e PORT=3000 \
  campus:latest
Always mount /pb_data as a persistent volume. Without this, your database and uploaded files will be lost when the container is removed.

Docker Compose

For easier management, you can use Docker Compose:
version: '3.8'

services:
  campus:
    build: .
    ports:
      - "8080:8080"
    volumes:
      - ./pb_data:/pb_data
    environment:
      - PORT=8080
      - PUBLIC_ENABLE_ANALYTICS=false
      - MEDIA_MAX_IMAGE_SIZE_MB=10
    restart: unless-stopped
Run with:
docker-compose up -d

Deployment to Platforms

Railway

Railway automatically detects the Dockerfile and builds/deploys the application:
  1. Connect your GitHub repository
  2. Railway will detect the Dockerfile
  3. Set environment variables in Railway dashboard
  4. Railway provides persistent storage automatically

Fly.io

Create a fly.toml configuration:
app = "campus"

[build]
  dockerfile = "Dockerfile"

[env]
  PORT = "8080"

[[services]]
  internal_port = 8080
  protocol = "tcp"

  [[services.ports]]
    handlers = ["http"]
    port = 80

  [[services.ports]]
    handlers = ["tls", "http"]
    port = 443

[mounts]
  source = "pb_data"
  destination = "/pb_data"
Deploy with:
fly deploy

Troubleshooting

Check Container Logs

docker logs campus

Access Container Shell

docker exec -it campus sh

Check Running Processes

docker exec campus ps aux

Verify Port Binding

docker port campus

Common Issues

Container exits immediately
  • Check logs: docker logs campus
  • Verify /pb_data directory permissions
  • Ensure port 8080 is not already in use
Cannot access application
  • Verify port mapping: docker ps
  • Check firewall settings
  • Ensure PORT environment variable is set correctly
PocketBase data lost after restart
  • Verify volume mount: docker inspect campus
  • Ensure /pb_data is mounted to a persistent location

Next Steps

Build docs developers (and LLMs) love