Skip to content
Open
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
42 changes: 33 additions & 9 deletions DOCKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Morphik Core provides a streamlined Docker-based setup that includes all necessa

## Prerequisites

- Docker and Docker Compose installed on your system
- Docker and Docker Compose 2.24.0 or newer installed on your system
- At least 10GB of free disk space (for models and data)
- 8GB+ RAM recommended

Expand All @@ -16,7 +16,19 @@ git clone https://github.com/morphik-org/morphik-core.git
cd morphik-core
```

2. First-time setup:
2. Create a `.env` file for Docker secrets:
```bash
umask 077
cat > .env <<EOF
JWT_SECRET_KEY=$(openssl rand -hex 32)
SESSION_SECRET_KEY=$(openssl rand -hex 32)
LOCAL_URI_PASSWORD=
EOF
```

If `openssl` is not available, set `JWT_SECRET_KEY` and `SESSION_SECRET_KEY` to separate non-placeholder random hex values with at least 32 characters. Leave `LOCAL_URI_PASSWORD` blank unless you need `/local/generate_uri`. Shell-exported values for these same variables are also supported for CI or scripted deployments.

3. First-time setup:
```bash
docker compose up --build
```
Expand All @@ -29,13 +41,13 @@ This command will:

The initial setup may take 5-10 minutes depending on your internet speed, as it needs to download the AI models.

3. For subsequent runs:
4. For subsequent runs:
```bash
docker compose up # Start all services
docker compose down # Stop all services
```

4. To completely reset (will delete all data and models):
5. To completely reset (will delete all data and models):
```bash
docker compose down -v
```
Expand Down Expand Up @@ -84,15 +96,21 @@ storage_path = "/app/storage"

### 3. Environment Variables

Create a `.env` file to customize these settings:
Create a `.env` file before starting Docker. Docker Compose loads this file for both the API and worker services:

```bash
JWT_SECRET_KEY=your-secure-key-here # Important: Change in production
JWT_SECRET_KEY=<32+-character-random-hex-secret> # Important: Change in production
SESSION_SECRET_KEY=<32+-character-random-hex-secret> # Important: Change in production
LOCAL_URI_PASSWORD=<32+-character-random-hex-secret> # Only needed for /local/generate_uri
OPENAI_API_KEY=sk-... # Only if using OpenAI
HOST=0.0.0.0 # Leave as is for Docker
PORT=8000 # Change if needed
```

When `bypass_auth_mode = false`, `JWT_SECRET_KEY` and `SESSION_SECRET_KEY` must be non-empty, non-placeholder values with at least 32 characters. If `LOCAL_URI_PASSWORD` is unset or blank, `/local/generate_uri` is disabled; if you set it, use a non-placeholder value with at least 32 characters. When writing secrets to `.env`, use hex values such as `openssl rand -hex 32` so Docker Compose does not treat characters like `$`, quotes, or `#` as env-file syntax.

Upgrade note: existing authenticated Docker deployments must verify that `JWT_SECRET_KEY` and `SESSION_SECRET_KEY` are both non-placeholder random values with at least 32 characters before pulling an image with this validation. Use the same values for both the `morphik` API service and the `worker` service through `.env` or shell-exported environment variables. If `LOCAL_URI_PASSWORD` is set, replace weak or placeholder values with a non-placeholder 32+ character value, or clear it to disable `/local/generate_uri`.

### 4. Custom Configuration

To use your own configuration:
Expand Down Expand Up @@ -135,12 +153,17 @@ services:
- Check PostgreSQL is healthy: `docker compose ps`
- Verify database connection: `docker compose exec postgres psql -U morphik -d morphik`

3. **Model Download Issues**
3. **Auth Secret Issues**
- If startup fails with `JWT_SECRET_KEY` or `SESSION_SECRET_KEY` validation errors, set both values in `.env` to non-placeholder random strings with at least 32 characters and restart
- If startup fails with `LOCAL_URI_PASSWORD` validation errors, replace it with a non-placeholder value with at least 32 characters, or clear it to disable `/local/generate_uri`
- If `/local/generate_uri` returns HTTP `503` with `LOCAL_URI_PASSWORD is not configured; /local/generate_uri is disabled`, set `LOCAL_URI_PASSWORD` in `.env` to a non-placeholder value with at least 32 characters before using that endpoint

4. **Model Download Issues**
- Check Ollama logs: `docker compose logs ollama`
- Ensure enough disk space for models
- Try restarting Ollama: `docker compose restart ollama`

4. **Performance Issues**
5. **Performance Issues**
- Monitor resources: `docker stats`
- Ensure sufficient RAM (8GB+ recommended)
- Check disk space: `df -h`
Expand All @@ -150,7 +173,8 @@ services:
For production environments:

1. **Security**:
- Change the default `JWT_SECRET_KEY`
- Use randomly generated `JWT_SECRET_KEY` and `SESSION_SECRET_KEY` values of at least 32 characters; do not rely on example or development defaults
- Set a randomly generated `LOCAL_URI_PASSWORD` of at least 32 characters before using `/local/generate_uri`
- Use proper network security groups
- Enable HTTPS (recommended: use a reverse proxy)
- Regularly update containers and dependencies
Expand Down
6 changes: 3 additions & 3 deletions core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from core.limits_utils import check_and_increment_limits
from core.logging_config import setup_logging
from core.middleware.profiling import ProfilingMiddleware
from core.local_uri import require_local_uri_password_configured
from core.models.auth import AuthContext
from core.models.chat import ChatMessage
from core.models.completion import CompletionResponse
Expand Down Expand Up @@ -970,14 +971,13 @@ async def get_available_models_for_selection(auth: AuthContext = Depends(verify_
async def generate_local_uri(
name: str = Form("admin"),
expiry_days: int = Form(5475), # 15 years
password_token: str = Form(...),
password_token: Optional[str] = Form(None),
server_mode: bool = Form(False),
) -> Dict[str, str]:
"""Generate a development URI for running Morphik locally."""
try:
# Authenticate with LOCAL_URI_PASSWORD
if not settings.LOCAL_URI_PASSWORD:
raise HTTPException(status_code=500, detail="LOCAL_URI_PASSWORD not configured")
require_local_uri_password_configured(settings.LOCAL_URI_PASSWORD)

if password_token != settings.LOCAL_URI_PASSWORD:
raise HTTPException(status_code=401, detail="Invalid authentication token")
Expand Down
94 changes: 86 additions & 8 deletions core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
# injecting variables.
load_local_env(override=True)

AUTH_SECRET_MIN_LENGTH = 32


class ParserXMLSettings(BaseModel):
max_tokens: int = 350
Expand Down Expand Up @@ -185,6 +187,72 @@ def get_settings() -> Settings:
em = "'{missing_value}' needed if '{field}' is set to '{value}'"
settings_dict = {}

def normalize_auth_secret(value: str) -> str:
normalized = value.strip()
if len(normalized) >= 2 and (
(normalized[0] == '"' and normalized[-1] == '"')
or (normalized[0] == "'" and normalized[-1] == "'")
):
normalized = normalized[1:-1].strip()
return normalized

def env_or_default(name: str, default: str) -> str:
value = os.environ.get(name)
if value is None:
return default
normalized = normalize_auth_secret(value)
return normalized or default

def validate_auth_secrets(secret_values: Dict[str, str], *, context: str) -> None:
insecure_values = {
"JWT_SECRET_KEY": {
"dev-secret-key",
"<replace-with-strong-random-secret>",
"your-secret-key-here",
"your-secure-jwt-key-here",
"your-super-secret-key-change-in-production",
},
"SESSION_SECRET_KEY": {
"<replace-with-another-strong-secret>",
"super-secret-dev-session-key",
"your-secure-session-key-here",
"your-session-secret-key-change-in-production",
},
"LOCAL_URI_PASSWORD": {
"<replace-with-local-uri-password>",
"change-me-local-uri-password",
"local-uri-password",
"your-local-uri-password-here",
},
}
missing = [name for name, value in secret_values.items() if not value]
if missing:
secret_names = ", ".join(missing)
verb = "is" if len(missing) == 1 else "are"
raise ValueError(f"{secret_names} {verb} required {context}")

placeholders = [
name
for name, value in secret_values.items()
if value in insecure_values[name] or (value.startswith("<") and value.endswith(">"))
]
if placeholders:
secret_names = ", ".join(placeholders)
verb = "uses" if len(placeholders) == 1 else "use"
raise ValueError(
f"{secret_names} {verb} an example or development default value; "
f"set non-placeholder values {context}"
)

short = [name for name, value in secret_values.items() if len(value) < AUTH_SECRET_MIN_LENGTH]
if short:
secret_names = ", ".join(short)
verb = "is" if len(short) == 1 else "are"
raise ValueError(
f"{secret_names} {verb} too short; set values with at least "
f"{AUTH_SECRET_MIN_LENGTH} characters {context}"
)

# Load API config
settings_dict.update(
{
Expand All @@ -207,19 +275,32 @@ def get_settings() -> Settings:
)

# Load auth config
local_uri_password = normalize_auth_secret(os.environ.get("LOCAL_URI_PASSWORD", ""))
settings_dict.update(
{
"JWT_ALGORITHM": config["auth"]["jwt_algorithm"],
"JWT_SECRET_KEY": os.environ.get("JWT_SECRET_KEY", "dev-secret-key"), # Default for bypass mode
"SESSION_SECRET_KEY": os.environ.get("SESSION_SECRET_KEY", "super-secret-dev-session-key"),
"JWT_SECRET_KEY": env_or_default("JWT_SECRET_KEY", "dev-secret-key"), # Default for bypass mode
"SESSION_SECRET_KEY": env_or_default("SESSION_SECRET_KEY", "super-secret-dev-session-key"),
"LOCAL_URI_PASSWORD": local_uri_password or None,
"bypass_auth_mode": config["auth"].get("bypass_auth_mode", config["auth"].get("dev_mode", False)),
"dev_user_id": config["auth"].get("dev_user_id", config["auth"].get("dev_entity_id", "dev_user")),
}
)

# Only require JWT_SECRET_KEY in non-dev mode
if not settings_dict["bypass_auth_mode"] and "JWT_SECRET_KEY" not in os.environ:
raise ValueError("JWT_SECRET_KEY is required when bypass_auth_mode is disabled")
# Authenticated mode must not start with missing, example, or weak signing secrets.
if not settings_dict["bypass_auth_mode"]:
signing_secret_values = {
"JWT_SECRET_KEY": normalize_auth_secret(os.environ.get("JWT_SECRET_KEY", "")),
"SESSION_SECRET_KEY": normalize_auth_secret(os.environ.get("SESSION_SECRET_KEY", "")),
}
validate_auth_secrets(signing_secret_values, context="when bypass_auth_mode is disabled")
settings_dict.update(signing_secret_values)

if settings_dict["LOCAL_URI_PASSWORD"]:
validate_auth_secrets(
{"LOCAL_URI_PASSWORD": settings_dict["LOCAL_URI_PASSWORD"]},
context="before using /local/generate_uri",
)

# Load registered models if available
if "registered_models" in config:
Expand Down Expand Up @@ -438,9 +519,6 @@ def get_settings() -> Settings:

settings_dict["TELEMETRY_ENABLED"] = os.getenv("TELEMETRY", "").strip().lower() != "false"

# Load LOCAL_URI_PASSWORD from environment
settings_dict["LOCAL_URI_PASSWORD"] = os.environ.get("LOCAL_URI_PASSWORD")

# Load LiteLLM config (dummy API key for providers that don't need auth)
settings_dict["LITELLM_DUMMY_API_KEY"] = os.environ.get("LITELLM_DUMMY_API_KEY", "ollama")

Expand Down
11 changes: 11 additions & 0 deletions core/local_uri.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from typing import Optional

from fastapi import HTTPException


LOCAL_URI_PASSWORD_DISABLED_DETAIL = "LOCAL_URI_PASSWORD is not configured; /local/generate_uri is disabled"


def require_local_uri_password_configured(local_uri_password: Optional[str]) -> None:
if not local_uri_password:
raise HTTPException(status_code=503, detail=LOCAL_URI_PASSWORD_DISABLED_DETAIL)
Loading