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:
- Stage 1: Download PocketBase binary
- Stage 2: Build SvelteKit application
- 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:
- Starts PocketBase on internal port 8090
- Starts SvelteKit on internal port 3000
- 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:
Railway
Railway automatically detects the Dockerfile and builds/deploys the application:
- Connect your GitHub repository
- Railway will detect the Dockerfile
- Set environment variables in Railway dashboard
- 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:
Troubleshooting
Check Container Logs
Access Container Shell
docker exec -it campus sh
Check Running Processes
docker exec campus ps aux
Verify Port Binding
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