Deploying Atlas with Docker and Railway
#atlas#devlog#tutorial#how-to#infrastructure
David OlssonAtlas 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.
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:
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:
[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:
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:
| Variable | Purpose |
|---|---|
LLM_API_KEY | Required. Your LLM provider key. |
LLM_BASE_URL | Defaults to https://api.openai.com/v1. |
LLM_MODEL_NAME | Defaults to gpt-4o-mini. |
MAX_CONCURRENT_LLM_THREADS | How many LLM calls run in parallel (default 5). |
OASIS_DEFAULT_MAX_ROUNDS | Default simulation depth (default 10). |
FLASK_DEBUG | Set 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:
# 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
- Push the repo to GitHub and connect it to a new Railway project.
- Railway detects
railway.tomland builds from the Dockerfile. - Add your environment variables in the Railway Variables panel.
- Add a Railway volume, mount it at
/app/backend/uploads. - Deploy. The healthcheck at
/healthconfirms the service is up.
That is the whole stack. One image, one process, one volume, and a handful of env vars.