Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
.git
.github
.gitignore
.idea
.vscode
data/
logs/
dist/
node_modules/
config.yaml
config.local.yaml
ai-gateway
*.exe
*.dll
*.so
*.dylib
*.test
*.out
*.swp
*.swo
.DS_Store
Thumbs.db
TODO.md
HN.txt
71 changes: 71 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# ---- Build stage ----
FROM golang:1.24-alpine AS builder

# gcc and musl-dev required for CGO (go-sqlite3)
RUN apk add --no-cache gcc musl-dev

WORKDIR /app

# Cache dependency downloads
COPY go.mod go.sum ./
RUN go mod download

COPY . .

ARG VERSION=dev
ARG COMMIT=unknown

# CGO_ENABLED=1 is required — go-sqlite3 uses CGO bindings
RUN CGO_ENABLED=1 go build \
-ldflags "-s -w \
-X main.version=${VERSION} \
-X main.commit=${COMMIT} \
-X main.buildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-o /ai-gateway ./cmd/server

# Build password-hash utility (pure Go, no CGO needed)
RUN mkdir -p cmd/hashpw && cat > cmd/hashpw/main.go <<'GOEOF'
package main

import (
"fmt"
"os"

"golang.org/x/crypto/bcrypt"
)

func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "Usage: hashpw <password>")
os.Exit(1)
}
hash, err := bcrypt.GenerateFromPassword([]byte(os.Args[1]), bcrypt.DefaultCost)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Print(string(hash))
}
GOEOF
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -o /hashpw ./cmd/hashpw

# ---- Runtime stage ----
FROM alpine:3.21

# ca-certificates needed for HTTPS calls to upstream LLM providers
RUN apk add --no-cache ca-certificates

WORKDIR /app
RUN mkdir -p data logs

COPY --from=builder /ai-gateway .
COPY --from=builder /hashpw /usr/local/bin/hashpw
COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

EXPOSE 8090

HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8090/health || exit 1

ENTRYPOINT ["/entrypoint.sh"]
113 changes: 113 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Docker Deployment

## Building the Image

From the repository root:

```bash
docker build -f docker/Dockerfile -t ai-gateway .
```

With version info:

```bash
docker build -f docker/Dockerfile \
--build-arg VERSION=$(git describe --tags --always) \
--build-arg COMMIT=$(git rev-parse --short HEAD) \
-t ai-gateway .
```

## Configuration

AI Gateway reads all settings from a single `config.yaml` file. Copy `docker/config.example.yaml` as a starting point and customise it with your provider API keys and preferences.

Mount the config file into the container at `/app/config.yaml`.

### Admin Password

Three options, from most to least manual:

**1. Pre-compute the bcrypt hash (recommended for PaaS)**

Generate a hash using the image itself:

```bash
docker run --rm ai-gateway hashpw 'your-secure-password'
```

Paste the output into your config:

```yaml
admin:
username: admin
password_hash: "$2a$10$..."
session_secret: "see below"
```

**2. Use the `ADMIN_PASSWORD` environment variable**

Set `ADMIN_PASSWORD` in your PaaS environment. The entrypoint generates the bcrypt hash and injects it into the config on every container start. The variable is cleared from the process environment before the application launches.

This works even if the config mount is read-only.

**3. Use the setup wizard**

Set `password_hash` to `__SETUP_REQUIRED__` (or leave it empty). The app serves a setup page at `/setup` on first boot where you set the password through the browser.

Note: the setup wizard writes the new hash back to `config.yaml`. This requires the config file to be writable inside the container.

### Session Secret

The `session_secret` field signs admin session cookies. Generate one with:

```bash
openssl rand -hex 16
```

If left empty, the app auto-generates a secret and writes it back to the config file (requires writable config). For read-only mounts, always provide a pre-generated value.

## Running

```bash
docker run -d \
--name ai-gateway \
-p 8090:8090 \
-v /path/to/your/config.yaml:/app/config.yaml \
-v ai-gateway-data:/app/data \
ai-gateway
```

### Persistent Storage

| Container Path | Purpose | Notes |
|---|---|---|
| `/app/config.yaml` | Configuration file | Mount from host or PaaS config |
| `/app/data/` | SQLite database | **Must be persistent** — contains clients, API keys, request logs, usage stats |
| `/app/logs/` | Application log files | Optional — mount if you need log persistence beyond container lifetime |

The SQLite database is the only stateful component. If the volume backing `/app/data` is lost, all client configurations and API keys are gone.

### Environment Variables

| Variable | Description |
|---|---|
| `ADMIN_PASSWORD` | If set, the entrypoint generates a bcrypt hash and updates `password_hash` in the config before starting. Cleared from the environment after use. |

All other configuration is via `config.yaml`. The application does not read environment variables directly.

### Ports

The default listen port is `8090` (configurable via `server.port` in `config.yaml`).

| Endpoint | Purpose |
|---|---|
| `/health` | Health check (no auth) |
| `/v1/chat/completions` | OpenAI-compatible API (Bearer token) |
| `/admin` | Admin dashboard (session auth) |
| `/metrics` | Prometheus metrics (basic auth, if enabled) |

## Build Notes

- **CGO is required.** The SQLite driver (`go-sqlite3`) uses CGO bindings. The Dockerfile uses `golang:1.24-alpine` with `gcc` and `musl-dev` for compilation.
- **Static assets are embedded** in the Go binary via `embed`. No additional files need to be copied to the runtime image.
- The runtime image is `alpine:3.21` with only `ca-certificates` added (needed for HTTPS calls to upstream LLM providers).
81 changes: 81 additions & 0 deletions docker/config.example.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
server:
host: 0.0.0.0
port: 8090
https:
enabled: false
cert_file: ""
key_file: ""

admin:
username: admin
# Bcrypt hash of the admin password.
# Generate with: docker run --rm <image> hashpw 'your-password'
# Or set ADMIN_PASSWORD env var and the entrypoint will fill this in.
# Leave as __SETUP_REQUIRED__ to use the web-based setup wizard on first boot.
password_hash: "__SETUP_REQUIRED__"
# Random hex string used to sign session cookies.
# Generate with: openssl rand -hex 16
# If left empty the app will auto-generate one (requires writable config).
session_secret: ""

# Configure one or more upstream LLM providers.
providers:
# gemini:
# type: gemini
# api_key: ""
# default_model: gemini-2.5-flash
# allowed_models:
# - gemini-2.5-flash
# - gemini-2.5-pro
# timeout_seconds: 120
#
# openai:
# type: openai
# api_key: ""
# default_model: gpt-4o
# timeout_seconds: 120
#
# anthropic:
# type: anthropic
# api_key: ""
# default_model: claude-sonnet-4-20250514
# timeout_seconds: 120
#
# mistral:
# type: mistral
# api_key: ""
# default_model: mistral-large-latest
#
# ollama:
# type: ollama
# base_url: http://host.docker.internal:11434/v1
# default_model: llama3.2
#
# openrouter:
# type: openrouter
# api_key: ""
# default_model: anthropic/claude-sonnet-4

defaults:
rate_limit:
requests_per_minute: 60
requests_per_hour: 1000
requests_per_day: 10000
quota:
max_input_tokens_per_day: 1000000
max_output_tokens_per_day: 500000
max_requests_per_day: 1000
max_input_tokens: 1000000
max_output_tokens: 8192

database:
path: ./data/gateway.db

logging:
level: info
file: ./logs/gateway.log

# prometheus:
# enabled: true
# username: prometheus
# password: "generate-a-secure-password"
42 changes: 42 additions & 0 deletions docker/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/bin/sh
set -e

# Allow running bundled utilities directly, e.g.:
# docker run --rm <image> hashpw 'mypassword'
case "${1:-}" in
hashpw)
exec /usr/local/bin/hashpw "$2"
;;
esac

CONFIG_PATH="/app/config.yaml"

# If ADMIN_PASSWORD is set and a config file exists, generate a bcrypt hash
# and replace the password_hash value in the config.
if [ -n "$ADMIN_PASSWORD" ] && [ -f "$CONFIG_PATH" ]; then
HASH=$(hashpw "$ADMIN_PASSWORD")
export HASH

awk '
/password_hash:/ {
match($0, /^[[:space:]]*/);
indent = substr($0, RSTART, RLENGTH);
print indent "password_hash: " ENVIRON["HASH"];
next
}
{ print }
' "$CONFIG_PATH" > /tmp/config.yaml

# Try to update in place; if the mount is read-only, use the copy instead
if cp /tmp/config.yaml "$CONFIG_PATH" 2>/dev/null; then
rm /tmp/config.yaml
else
CONFIG_PATH="/tmp/config.yaml"
echo "Config mount is read-only; using modified copy."
fi

unset ADMIN_PASSWORD HASH
echo "Admin password updated from ADMIN_PASSWORD environment variable."
fi

exec /app/ai-gateway -config "$CONFIG_PATH" "$@"