Skip to content

feat: auto-run database migrations on container startup#8

Open
damienriehl wants to merge 5 commits intomainfrom
feat/auto-migrate-on-startup
Open

feat: auto-run database migrations on container startup#8
damienriehl wants to merge 5 commits intomainfrom
feat/auto-migrate-on-startup

Conversation

@damienriehl
Copy link
Contributor

@damienriehl damienriehl commented Mar 19, 2026

Summary

  • Entrypoint script (scripts/entrypoint.sh): runs alembic upgrade head before starting the application, with a /tmp marker file to skip on uvicorn --reload re-invocations
  • Dockerfile: ENTRYPOINT delegates to entrypoint.sh, CMD remains uvicorn
  • compose.yaml: mount entrypoint script + set entrypoint/command so it works without rebuilding the image

Context

An 18MB OWL import was failing with "Could not reach the server" — root cause was a missing github_hook_id column (4 migrations behind). The unhandled 500 lacked CORS headers, causing the browser to treat it as a network error. This entrypoint prevents schema drift by auto-migrating on every container start.

Test plan

  • docker compose up -d api — logs should show "Running database migrations..." once before uvicorn starts
  • Add a new migration, restart container — migration should apply automatically
  • File change triggers uvicorn reload — migration should NOT re-run (marker file)
  • Fresh docker compose up from rebuilt image — migrations run from ENTRYPOINT

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Auto-migration on application startup
    • Railway deployment configuration support
  • Improvements

    • Added connection timeouts for database and Redis connections
    • PostgreSQL connection string auto-conversion for async driver compatibility
    • Docker build optimization with dependency pre-caching and context cleanup

damienriehl and others added 4 commits March 14, 2026 16:20
- Dockerfile.prod: add git/libgit2-dev deps, /data/repos dir, pre-download
  sentence-transformers model
- config.py: auto-convert postgresql:// to postgresql+asyncpg:// for Railway
- railway.json: use Dockerfile.prod and run Alembic migrations on deploy

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Redis pool.ping() has no default socket timeout, causing the API to
hang indefinitely if Redis is unreachable at startup. Similarly, asyncpg
has no connect timeout by default. Add explicit timeouts so startup
gracefully degrades instead of blocking forever.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The .env file was being copied into the Docker image by railway up,
causing Pydantic Settings to read localhost URLs instead of Railway's
injected environment variables. This made the API hang on startup
trying to connect to localhost:5432/6379 inside the container.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add entrypoint script that runs `alembic upgrade head` before starting
the application. Prevents schema drift when the API image or mounted
code has new migrations but the database hasn't been migrated.

- New scripts/entrypoint.sh: runs migrations then exec's CMD
  Uses /tmp marker to skip on uvicorn --reload re-invocations
- Dockerfile: ENTRYPOINT delegates to entrypoint.sh
- compose.yaml: mount entrypoint.sh + set entrypoint/command for dev

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Mar 19, 2026

📝 Walkthrough

Walkthrough

Changes implement automated database migrations on container startup via a new entrypoint script and update Docker configurations to support this flow. Additional timeouts are configured for PostgreSQL and Redis connections, and Railway deployment configuration is added with production Dockerfile enhancements including git dependencies and pre-cached ML models.

Changes

Cohort / File(s) Summary
Docker Build Configuration
.dockerignore, Dockerfile, Dockerfile.prod
Added .dockerignore to exclude development/build artifacts. Updated development Dockerfile to integrate entrypoint script for auto-migration on startup. Enhanced production Dockerfile with git dependencies and pre-downloaded ML models for sentence-transformers.
Container Orchestration
compose.yaml
Explicitly configured api service to use /usr/local/bin/entrypoint.sh as entrypoint with volume mount enabling hot-reload of the script without image rebuild.
Application Configuration
ontokit/core/config.py, ontokit/core/database.py, ontokit/main.py
Added Pydantic field validator to convert postgresql:// URLs to postgresql+asyncpg:// format. Set explicit 30-second timeout for database connections and 10-second timeouts for Redis socket operations.
Migration & Deployment
scripts/entrypoint.sh, railway.json
Added shell entrypoint script to run Alembic migrations once per container lifetime before starting uvicorn. Added Railway deployment manifest specifying production Dockerfile and release migration command.

Sequence Diagram

sequenceDiagram
    participant Container as Container Startup
    participant Entrypoint as entrypoint.sh
    participant Alembic as Alembic Migrations
    participant Database as PostgreSQL DB
    participant Uvicorn as Uvicorn App Server

    Container->>Entrypoint: Execute ENTRYPOINT
    Entrypoint->>Entrypoint: Check /tmp/.migrations_done marker
    alt Marker does not exist
        Entrypoint->>Alembic: Run 'alembic upgrade head'
        Alembic->>Database: Apply pending migrations
        Database-->>Alembic: Migration complete
        Alembic-->>Entrypoint: Exit success
        Entrypoint->>Entrypoint: Create /tmp/.migrations_done marker
    else Marker exists
        Entrypoint->>Entrypoint: Skip migrations
    end
    Entrypoint->>Uvicorn: exec CMD (uvicorn on 0.0.0.0:8000)
    Uvicorn->>Database: Connect with 30s timeout
    Database-->>Uvicorn: Connected
    Uvicorn-->>Container: App running
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐰 Migrations hopping 'round the pod,
Before the app begins its mod,
With timeouts set and scripts so neat,
We're Railway-bound for a smooth fleet!

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: auto-running database migrations on container startup via an entrypoint script.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/auto-migrate-on-startup

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@JohnRDOrazio JohnRDOrazio changed the base branch from release/v0.2.0 to main March 23, 2026 10:49
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (5)
Dockerfile (1)

50-52: Consider using absolute path in ENTRYPOINT.

The entrypoint relies on /usr/local/bin being in PATH. While this works, using the absolute path is more explicit and avoids potential PATH-related issues:

Suggested change
-ENTRYPOINT ["entrypoint.sh"]
+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Dockerfile` around lines 50 - 52, The ENTRYPOINT currently uses a relative/
PATH-resolved script name "entrypoint.sh", which can fail if PATH doesn't
include /usr/local/bin; update the Dockerfile ENTRYPOINT to reference the script
with its absolute path (e.g., /usr/local/bin/entrypoint.sh) and ensure the
referenced file (entrypoint.sh) is present in the image at that absolute
location and marked executable; target the ENTRYPOINT symbol and the
entrypoint.sh file when making the change.
Dockerfile.prod (1)

50-51: Production Dockerfile lacks entrypoint for auto-migrations.

Unlike the development Dockerfile which uses ENTRYPOINT ["entrypoint.sh"] to run migrations on startup, Dockerfile.prod relies solely on Railway's releaseCommand. If this image is deployed outside Railway (e.g., Kubernetes, ECS), migrations won't run automatically.

Consider adding the entrypoint script for consistency across deployment targets:

Add entrypoint to Dockerfile.prod
 # Copy alembic configuration for migrations
 COPY --chown=ontokit:ontokit alembic.ini ./
 COPY --chown=ontokit:ontokit alembic/ ./alembic/
 
+# Copy entrypoint script (runs migrations before starting the app)
+COPY --chown=ontokit:ontokit scripts/entrypoint.sh /usr/local/bin/entrypoint.sh
+RUN chmod +x /usr/local/bin/entrypoint.sh
+
 # Switch to non-root user
 USER ontokit

And update the CMD to use ENTRYPOINT:

+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
 CMD ["uvicorn", "ontokit.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Dockerfile.prod` around lines 50 - 51, Dockerfile.prod currently only sets
CMD to run uvicorn, so automatic migrations (handled by entrypoint.sh in dev)
won't run when deployed outside Railway; add an ENTRYPOINT that points to the
existing entrypoint.sh before the CMD line and ensure the entrypoint.sh is
executable and performs the migrations then execs the CMD command (so uvicorn
receives signals). Update Dockerfile.prod to include ENTRYPOINT
["./entrypoint.sh"] (or the correct path used in the repo) and keep the CMD
["uvicorn", "ontokit.main:app", "--host", "0.0.0.0", "--port", "8000",
"--workers", "4"] so entrypoint.sh runs migrations and then execs uvicorn.
railway.json (1)

1-9: Redundant migration execution between release command and entrypoint.

The releaseCommand runs alembic upgrade head during Railway deployment, while scripts/entrypoint.sh also runs the same command on container start. This creates redundant migration attempts:

  1. Railway runs releaseCommand before deployment
  2. New containers start and run entrypoint.sh which also calls alembic upgrade head

Alembic uses PostgreSQL advisory locks to prevent concurrent execution, so this is safe but wasteful. Consider either:

  • Removing the releaseCommand and relying solely on the entrypoint (consistent across all deployment targets)
  • Or setting an environment variable in Railway to skip the entrypoint migration check
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@railway.json` around lines 1 - 9, The deploy config runs "alembic upgrade
head" via the releaseCommand while scripts/entrypoint.sh runs the same migration
on container start, causing redundant attempts; fix by choosing one strategy:
either remove the "releaseCommand": "alembic upgrade head" from railway.json so
migrations only run in scripts/entrypoint.sh (locate the releaseCommand key in
railway.json and delete it), or set a Railway environment variable (e.g.,
SKIP_ENTRYPOINT_MIGRATIONS=true) and update scripts/entrypoint.sh to skip
running alembic when that env var is set (modify the migration logic in
scripts/entrypoint.sh to check SKIP_ENTRYPOINT_MIGRATIONS before calling alembic
upgrade head).
scripts/entrypoint.sh (2)

7-11: Gate migration execution to avoid replica startup contention.

This guard is container-local only. With multiple app replicas, each instance can still run Alembic on boot. Prefer env-gating here so only one designated instance/job performs migrations.

Proposed change
 MARKER="/tmp/.migrations_done"
-if [ ! -f "$MARKER" ]; then
+RUN_MIGRATIONS="${RUN_MIGRATIONS:-1}"
+if [ "$RUN_MIGRATIONS" = "1" ] && [ ! -f "$MARKER" ]; then
     echo "Running database migrations..."
     python -m alembic upgrade head
     touch "$MARKER"
+elif [ "$RUN_MIGRATIONS" != "1" ]; then
+    echo "Skipping migrations (RUN_MIGRATIONS=$RUN_MIGRATIONS)"
 fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/entrypoint.sh` around lines 7 - 11, The script currently runs Alembic
migrations based only on a container-local MARKER which allows multiple replicas
to run migrations concurrently; update the entrypoint.sh to gate running "python
-m alembic upgrade head" behind an environment flag (e.g., RUN_MIGRATIONS or
MIGRATION_LEADER) so only the designated instance performs migrations: check
that the env var is set to "true" (or non-empty) AND MARKER does not exist
before executing the alembic command, otherwise skip migrations; keep the
existing MARKER touch logic (touch "$MARKER") after a successful run so
subsequent restarts of the same container still skip re-running migrations, and
ensure the conditional uses the same variable name you choose so callers can
control which replica/job runs migrations.

2-2: Harden shell error handling flags.

Use strict mode (set -euo pipefail) to fail on unset variables and pipe failures too, not just non-zero exits.

Proposed change
-set -e
+set -euo pipefail
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/entrypoint.sh` at line 2, Replace the existing shell invocation "set
-e" with strict mode flags "set -euo pipefail" in the entrypoint script so the
shell fails on unset variables and pipe failures as well as non-zero exits;
locate the line containing "set -e" and change it to "set -euo pipefail".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Dockerfile.prod`:
- Around line 30-31: The pre-download RUN that invokes
SentenceTransformer('all-MiniLM-L6-v2') runs as root so the model cache lives
under root and the app (which runs as USER ontokit) cannot access it; either
move that RUN to after the Dockerfile USER ontokit switch so the model is cached
into ontokit’s home, or keep it as root but set an explicit shared cache
directory (via env vars like TRANSFORMERS_CACHE or HF_HOME) and ensure
permissions are adjusted and the ontokit user owns that cache directory so
SentenceTransformer can find the model at runtime.

In `@ontokit/core/config.py`:
- Around line 35-41: The method convert_postgres_scheme is missing type
annotations; add typing to satisfy MyPy by importing Any from typing and
annotating the signature as `@classmethod` def convert_postgres_scheme(cls: type,
v: Any) -> Any so the validator's pre-processing input and output are typed;
keep the existing logic and return types unchanged.

---

Nitpick comments:
In `@Dockerfile`:
- Around line 50-52: The ENTRYPOINT currently uses a relative/ PATH-resolved
script name "entrypoint.sh", which can fail if PATH doesn't include
/usr/local/bin; update the Dockerfile ENTRYPOINT to reference the script with
its absolute path (e.g., /usr/local/bin/entrypoint.sh) and ensure the referenced
file (entrypoint.sh) is present in the image at that absolute location and
marked executable; target the ENTRYPOINT symbol and the entrypoint.sh file when
making the change.

In `@Dockerfile.prod`:
- Around line 50-51: Dockerfile.prod currently only sets CMD to run uvicorn, so
automatic migrations (handled by entrypoint.sh in dev) won't run when deployed
outside Railway; add an ENTRYPOINT that points to the existing entrypoint.sh
before the CMD line and ensure the entrypoint.sh is executable and performs the
migrations then execs the CMD command (so uvicorn receives signals). Update
Dockerfile.prod to include ENTRYPOINT ["./entrypoint.sh"] (or the correct path
used in the repo) and keep the CMD ["uvicorn", "ontokit.main:app", "--host",
"0.0.0.0", "--port", "8000", "--workers", "4"] so entrypoint.sh runs migrations
and then execs uvicorn.

In `@railway.json`:
- Around line 1-9: The deploy config runs "alembic upgrade head" via the
releaseCommand while scripts/entrypoint.sh runs the same migration on container
start, causing redundant attempts; fix by choosing one strategy: either remove
the "releaseCommand": "alembic upgrade head" from railway.json so migrations
only run in scripts/entrypoint.sh (locate the releaseCommand key in railway.json
and delete it), or set a Railway environment variable (e.g.,
SKIP_ENTRYPOINT_MIGRATIONS=true) and update scripts/entrypoint.sh to skip
running alembic when that env var is set (modify the migration logic in
scripts/entrypoint.sh to check SKIP_ENTRYPOINT_MIGRATIONS before calling alembic
upgrade head).

In `@scripts/entrypoint.sh`:
- Around line 7-11: The script currently runs Alembic migrations based only on a
container-local MARKER which allows multiple replicas to run migrations
concurrently; update the entrypoint.sh to gate running "python -m alembic
upgrade head" behind an environment flag (e.g., RUN_MIGRATIONS or
MIGRATION_LEADER) so only the designated instance performs migrations: check
that the env var is set to "true" (or non-empty) AND MARKER does not exist
before executing the alembic command, otherwise skip migrations; keep the
existing MARKER touch logic (touch "$MARKER") after a successful run so
subsequent restarts of the same container still skip re-running migrations, and
ensure the conditional uses the same variable name you choose so callers can
control which replica/job runs migrations.
- Line 2: Replace the existing shell invocation "set -e" with strict mode flags
"set -euo pipefail" in the entrypoint script so the shell fails on unset
variables and pipe failures as well as non-zero exits; locate the line
containing "set -e" and change it to "set -euo pipefail".

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a8323202-fd9c-4de1-85c8-ecbbf810c7ae

📥 Commits

Reviewing files that changed from the base of the PR and between c47daaa and 7b1bd22.

📒 Files selected for processing (9)
  • .dockerignore
  • Dockerfile
  • Dockerfile.prod
  • compose.yaml
  • ontokit/core/config.py
  • ontokit/core/database.py
  • ontokit/main.py
  • railway.json
  • scripts/entrypoint.sh

Comment on lines +30 to +31
# Pre-download sentence-transformers model to avoid first-request latency
RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Model downloaded as root, but app runs as non-root user.

The model is pre-downloaded before USER ontokit (line 41), so it's cached under root's home directory. When the app runs as ontokit, sentence-transformers may not find the cached model and re-download it on first use.

Move the model download after switching to the ontokit user, or set the cache directory explicitly:

Option 1: Move after USER switch
 # Switch to non-root user
 USER ontokit
 
+# Pre-download sentence-transformers model to avoid first-request latency
+RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')"
+
 # Expose port
 EXPOSE 8000
Option 2: Use explicit cache directory
 # Pre-download sentence-transformers model to avoid first-request latency
-RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')"
+ENV SENTENCE_TRANSFORMERS_HOME=/home/ontokit/.cache/sentence-transformers
+RUN mkdir -p $SENTENCE_TRANSFORMERS_HOME && \
+    python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')" && \
+    chown -R ontokit:ontokit /home/ontokit/.cache
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Pre-download sentence-transformers model to avoid first-request latency
RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')"
# Pre-download sentence-transformers model to avoid first-request latency
ENV SENTENCE_TRANSFORMERS_HOME=/home/ontokit/.cache/sentence-transformers
RUN mkdir -p $SENTENCE_TRANSFORMERS_HOME && \
python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')" && \
chown -R ontokit:ontokit /home/ontokit/.cache
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Dockerfile.prod` around lines 30 - 31, The pre-download RUN that invokes
SentenceTransformer('all-MiniLM-L6-v2') runs as root so the model cache lives
under root and the app (which runs as USER ontokit) cannot access it; either
move that RUN to after the Dockerfile USER ontokit switch so the model is cached
into ontokit’s home, or keep it as root but set an explicit shared cache
directory (via env vars like TRANSFORMERS_CACHE or HF_HOME) and ensure
permissions are adjusted and the ontokit user owns that cache directory so
SentenceTransformer can find the model at runtime.

Comment on lines +35 to +41
@field_validator("database_url", mode="before")
@classmethod
def convert_postgres_scheme(cls, v):
"""Railway provides postgresql:// but SQLAlchemy async needs postgresql+asyncpg://."""
if isinstance(v, str) and v.startswith("postgresql://"):
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
return v
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add type annotations to fix MyPy error.

The pipeline fails with mypy: Function is missing a type annotation [no-untyped-def]. Per coding guidelines, MyPy strict mode is enforced. Add type hints:

Proposed fix
     `@field_validator`("database_url", mode="before")
     `@classmethod`
-    def convert_postgres_scheme(cls, v):
+    def convert_postgres_scheme(cls, v: object) -> object:
         """Railway provides postgresql:// but SQLAlchemy async needs postgresql+asyncpg://."""
         if isinstance(v, str) and v.startswith("postgresql://"):
             return v.replace("postgresql://", "postgresql+asyncpg://", 1)
         return v

As per coding guidelines: "Enable and enforce MyPy strict mode type checking with Python 3.11 target".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@field_validator("database_url", mode="before")
@classmethod
def convert_postgres_scheme(cls, v):
"""Railway provides postgresql:// but SQLAlchemy async needs postgresql+asyncpg://."""
if isinstance(v, str) and v.startswith("postgresql://"):
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
return v
`@field_validator`("database_url", mode="before")
`@classmethod`
def convert_postgres_scheme(cls, v: object) -> object:
"""Railway provides postgresql:// but SQLAlchemy async needs postgresql+asyncpg://."""
if isinstance(v, str) and v.startswith("postgresql://"):
return v.replace("postgresql://", "postgresql+asyncpg://", 1)
return v
🧰 Tools
🪛 GitHub Actions: Distribution

[error] 37-37: mypy: Function is missing a type annotation [no-untyped-def]

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ontokit/core/config.py` around lines 35 - 41, The method
convert_postgres_scheme is missing type annotations; add typing to satisfy MyPy
by importing Any from typing and annotating the signature as `@classmethod` def
convert_postgres_scheme(cls: type, v: Any) -> Any so the validator's
pre-processing input and output are typed; keep the existing logic and return
types unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants