lightweight go reverse proxy for ollama with bearer token authentication
go proxy ollama

init

+9
.env.example
···
···
+
# Authentication token (required)
+
# Generate a strong random token for production use
+
AUTH_TOKEN=your-secret-token-here
+
+
# Ollama server URL (optional, defaults to http://localhost:11434)
+
OLLAMA_URL=http://localhost:11434
+
+
# Proxy server port (optional, defaults to 8080)
+
PORT=8080
+30
.gitignore
···
···
+
# Binaries for programs and plugins
+
*.exe
+
*.exe~
+
*.dll
+
*.so
+
*.dylib
+
+
# Test binary, built with `go test -c`
+
*.test
+
+
# Output of the go build
+
ollama-proxy
+
+
# Go workspace file
+
go.work
+
+
# Environment variables
+
.env
+
.env.local
+
+
# IDE
+
.vscode/
+
.idea/
+
*.swp
+
*.swo
+
*~
+
+
# OS
+
.DS_Store
+
Thumbs.db
+21
Makefile
···
···
+
.PHONY: build test lint run clean help
+
+
build: ## Build the binary
+
go build -o ollama-proxy
+
+
test: ## Run tests
+
go test -v ./...
+
+
lint: ## Run linter
+
golangci-lint run
+
+
run: ## Run the application (requires AUTH_TOKEN env var)
+
go run .
+
+
clean: ## Remove build artifacts
+
rm -f ollama-proxy
+
+
help: ## Show this help message
+
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-15s\033[0m %s\n", $$1, $$2}'
+
+
.DEFAULT_GOAL := help
+38
README.md
···
···
+
# Ollama Proxy
+
+
A lightweight Go reverse proxy for Ollama with Bearer token authentication.
+
+
## Features
+
+
- Simple reverse proxy to Ollama API
+
- Bearer token authentication
+
- Easy configuration via environment variables
+
+
## Installation
+
+
```bash
+
go install github.com/ollama/ollama-proxy@latest
+
```
+
+
```bash
+
go build -o ollama-proxy
+
```
+
+
## Configuration
+
+
The proxy is configured via environment variables:
+
+
- `AUTH_TOKEN` (required): Bearer token for API authentication
+
- `OLLAMA_URL` (optional): Ollama server URL (default: `http://localhost:11434`)
+
- `PORT` (optional): Proxy server port (default: `8080`)
+
+
## Usage
+
+
### Start the proxy
+
+
```bash
+
export AUTH_TOKEN="your-secret-token"
+
export OLLAMA_URL="http://localhost:11434"
+
export PORT="8080"
+
./ollama-proxy
+
```
+10
go.mod
···
···
+
module github.com/julienrbrt/ollama-proxy
+
+
go 1.25
+
+
require (
+
github.com/go-chi/chi/v5 v5.1.0
+
gotest.tools/v3 v3.5.1
+
)
+
+
require github.com/google/go-cmp v0.5.9 // indirect
+6
go.sum
···
···
+
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
+
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
+
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
+
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
+
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
+52
main.go
···
···
+
package main
+
+
import (
+
"log"
+
"net/http"
+
"os"
+
+
"github.com/go-chi/chi/v5"
+
"github.com/go-chi/chi/v5/middleware"
+
)
+
+
const (
+
portEnv = "PORT"
+
authEnv = "AUTH_TOKEN"
+
ollamaEnv = "OLLAMA_URL"
+
)
+
+
func main() {
+
port := os.Getenv(portEnv)
+
if port == "" {
+
port = "8080"
+
}
+
+
ollamaURL := os.Getenv(ollamaEnv)
+
if ollamaURL == "" {
+
ollamaURL = "http://localhost:11434"
+
}
+
+
token := os.Getenv(authEnv)
+
if token == "" {
+
log.Fatal("AUTH_TOKEN environment variable is required")
+
}
+
+
proxy, err := newProxy(ollamaURL)
+
if err != nil {
+
log.Fatalf("failed to create proxy: %v", err)
+
}
+
+
r := chi.NewRouter()
+
r.Use(middleware.Logger)
+
r.Use(middleware.Recoverer)
+
r.Use(authMiddleware(token))
+
+
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
+
proxy.ServeHTTP(w, r)
+
})
+
+
log.Printf("Starting proxy server on port %s, forwarding to %s", port, ollamaURL)
+
if err := http.ListenAndServe(":"+port, r); err != nil {
+
log.Fatalf("server failed: %v", err)
+
}
+
}
+54
proxy.go
···
···
+
package main
+
+
import (
+
"fmt"
+
"net/http"
+
"net/http/httputil"
+
"net/url"
+
"strings"
+
)
+
+
// authMiddleware validates Bearer token authorization
+
func authMiddleware(token string) func(http.Handler) http.Handler {
+
return func(next http.Handler) http.Handler {
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
authHeader := r.Header.Get("Authorization")
+
if authHeader == "" {
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
+
return
+
}
+
+
parts := strings.SplitN(authHeader, " ", 2)
+
if len(parts) != 2 || parts[0] != "Bearer" {
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
+
return
+
}
+
+
if parts[1] == "" || parts[1] != token {
+
http.Error(w, "Unauthorized", http.StatusUnauthorized)
+
return
+
}
+
+
next.ServeHTTP(w, r)
+
})
+
}
+
}
+
+
// newProxy creates a reverse proxy for the target URL
+
func newProxy(targetURL string) (*httputil.ReverseProxy, error) {
+
if targetURL == "" {
+
return nil, fmt.Errorf("target URL cannot be empty")
+
}
+
+
target, err := url.Parse(targetURL)
+
if err != nil {
+
return nil, fmt.Errorf("invalid target URL: %w", err)
+
}
+
+
if target.Scheme == "" || target.Host == "" {
+
return nil, fmt.Errorf("invalid target URL: missing scheme or host")
+
}
+
+
proxy := httputil.NewSingleHostReverseProxy(target)
+
return proxy, nil
+
}
+118
proxy_test.go
···
···
+
package main
+
+
import (
+
"net/http"
+
"net/http/httptest"
+
"testing"
+
+
"gotest.tools/v3/assert"
+
)
+
+
func TestAuthMiddleware(t *testing.T) {
+
tests := []struct {
+
name string
+
token string
+
authHeader string
+
expectedStatus int
+
expectedBody string
+
}{
+
{
+
name: "valid token",
+
token: "test-token-123",
+
authHeader: "Bearer test-token-123",
+
expectedStatus: http.StatusOK,
+
expectedBody: "OK",
+
},
+
{
+
name: "invalid token",
+
token: "test-token-123",
+
authHeader: "Bearer wrong-token",
+
expectedStatus: http.StatusUnauthorized,
+
expectedBody: "Unauthorized\n",
+
},
+
{
+
name: "missing bearer prefix",
+
token: "test-token-123",
+
authHeader: "test-token-123",
+
expectedStatus: http.StatusUnauthorized,
+
expectedBody: "Unauthorized\n",
+
},
+
{
+
name: "missing authorization header",
+
token: "test-token-123",
+
authHeader: "",
+
expectedStatus: http.StatusUnauthorized,
+
expectedBody: "Unauthorized\n",
+
},
+
{
+
name: "empty token value",
+
token: "test-token-123",
+
authHeader: "Bearer ",
+
expectedStatus: http.StatusUnauthorized,
+
expectedBody: "Unauthorized\n",
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+
w.WriteHeader(http.StatusOK)
+
_, _ = w.Write([]byte("OK"))
+
})
+
+
middleware := authMiddleware(tt.token)
+
req := httptest.NewRequest(http.MethodGet, "/", nil)
+
if tt.authHeader != "" {
+
req.Header.Set("Authorization", tt.authHeader)
+
}
+
+
rr := httptest.NewRecorder()
+
middleware(handler).ServeHTTP(rr, req)
+
+
assert.Equal(t, tt.expectedStatus, rr.Code)
+
assert.Equal(t, tt.expectedBody, rr.Body.String())
+
})
+
}
+
}
+
+
func TestNewProxy(t *testing.T) {
+
tests := []struct {
+
name string
+
targetURL string
+
wantErr bool
+
}{
+
{
+
name: "valid http URL",
+
targetURL: "http://localhost:11434",
+
wantErr: false,
+
},
+
{
+
name: "valid https URL",
+
targetURL: "https://example.com",
+
wantErr: false,
+
},
+
{
+
name: "invalid URL",
+
targetURL: "://invalid",
+
wantErr: true,
+
},
+
{
+
name: "empty URL",
+
targetURL: "",
+
wantErr: true,
+
},
+
}
+
+
for _, tt := range tests {
+
t.Run(tt.name, func(t *testing.T) {
+
proxy, err := newProxy(tt.targetURL)
+
if tt.wantErr {
+
assert.Assert(t, err != nil)
+
assert.Assert(t, proxy == nil)
+
} else {
+
assert.NilError(t, err)
+
assert.Assert(t, proxy != nil)
+
}
+
})
+
}
+
}