Skip to content
scsiwyg
sign insign up
get startedhow it worksmcpscsiblogcommunityapiplaygroundswaggersign insign up
Atlas·Deploying Atlas with Docker and Railway16 Apr 2026David Olsson

Deploying Atlas with Docker and Railway

#atlas#devlog#tutorial#how-to#infrastructure

David OlssonDavid Olsson

Atlas is a Flask + Vue application. For a while it ran locally only. We needed a path to a hosted deployment that was simple to operate, cost-effective, and did not require managing a separate static host or a reverse proxy. This post walks through how we got there.

The architecture

We chose a single-container approach: one image that builds the Vue frontend and serves it through Flask in production. No separate CDN origin, no Nginx sidecar. Flask uses send_from_directory to serve the compiled dist/ assets and falls back to index.html for client-side routes.

The Dockerfile

We base on python:3.11 and layer Node 20 on top via the NodeSource setup script, which avoids the outdated Node version in Debian's package repos. The uv package manager is copied from its official image rather than installed via pip — faster and fully reproducible.

dockerfile
FROM python:3.11

RUN apt-get update \
  && apt-get install -y --no-install-recommends curl ca-certificates \
  && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
  && apt-get install -y --no-install-recommends nodejs \
  && rm -rf /var/lib/apt/lists/*

COPY --from=ghcr.io/astral-sh/uv:0.9.26 /uv /uvx /bin/

WORKDIR /app

# Dependency manifests first — preserves layer cache
COPY package.json package-lock.json ./
COPY frontend/package.json frontend/package-lock.json ./frontend/
COPY backend/pyproject.toml backend/uv.lock ./backend/

RUN npm ci \
  && npm ci --prefix frontend \
  && cd backend && uv sync --frozen

COPY . .
RUN npm run build

EXPOSE 5001
CMD ["bash", "-c", "cd /app/backend && uv run python run.py"]

Dependency manifests are copied before source so that npm ci and uv sync layers are only invalidated when lock files change, not on every source edit.

Local development with docker-compose

For running Atlas locally inside Docker we use a minimal compose file:

yaml
services:
  atlas:
    build: .
    container_name: atlas
    env_file:
      - .env
    ports:
      - "5001:5001"
    restart: unless-stopped
    volumes:
      - ./backend/uploads:/app/backend/uploads
      - ./backend/data:/app/backend/data

The two volume mounts give you persistent uploads and simulation data without rebuilding the image. Copy .env.example to .env, fill in your LLM_API_KEY, and run docker compose up --build.

Railway config

Railway picks up railway.toml from the repo root automatically:

toml
[build]
builder = "dockerfile"
dockerfilePath = "Dockerfile"

[deploy]
startCommand = "cd /app/backend && uv run python run.py"
healthcheckPath = "/health"
healthcheckTimeout = 60
restartPolicyType = "on_failure"
restartPolicyMaxRetries = 3

[deploy.envs]
FLASK_DEBUG = "False"

The health check hits /health — a lightweight endpoint that returns {"status": "ok"}. Railway will restart the container on failure, up to three times.

Railway injects a PORT environment variable at runtime. run.py reads it directly:

python
port = int(os.environ.get('PORT') or os.environ.get('FLASK_PORT', 5001))

You do not set PORT yourself. The lookup chain is PORT (Railway) then FLASK_PORT (explicit override) then 5001 (default).

Environment variables

All required config is in .env.example. On Railway you set these in the service's Variables panel:

VariablePurpose
LLM_API_KEYRequired. Your LLM provider key.
LLM_BASE_URLDefaults to https://api.openai.com/v1.
LLM_MODEL_NAMEDefaults to gpt-4o-mini.
MAX_CONCURRENT_LLM_THREADSHow many LLM calls run in parallel (default 5).
OASIS_DEFAULT_MAX_ROUNDSDefault simulation depth (default 10).
FLASK_DEBUGSet to False in production.

FLASK_PORT is not needed on Railway — PORT is injected automatically.

Config priority: env vars over settings.json

Atlas has an in-app settings panel that writes to settings.json. In production on Railway that file lives on the mounted volume at backend/uploads/settings.json. The config layer is explicit about precedence:

python
# Env vars always win; settings.json only fills gaps
LLM_API_KEY = os.environ.get('LLM_API_KEY') or _s.get('llm_api_key')

This means Railway-injected variables cannot be overridden by a settings file someone saved through the UI. The UI is a local-only convenience.

What to exclude from the image

.dockerignore keeps the image lean and avoids shipping secrets:

.env
backend/.venv
node_modules
frontend/node_modules
backend/data
backend/uploads

backend/data and backend/uploads are excluded from the build because they are bind-mounted at runtime. Shipping them in the image would bloat layers and shadow the volume mount.

Deploying

  1. Push the repo to GitHub and connect it to a new Railway project.
  2. Railway detects railway.toml and builds from the Dockerfile.
  3. Add your environment variables in the Railway Variables panel.
  4. Add a Railway volume, mount it at /app/backend/uploads.
  5. Deploy. The healthcheck at /health confirms the service is up.

That is the whole stack. One image, one process, one volume, and a handful of env vars.

Share
𝕏 Post