Skip to content

Deployment

Production runs on Railway with auto-deploy on push to main.

Services

ServicePlatformProduction URL
APIRailway (Dockerfile.api)api.mtllouer.com
WebRailway (Dockerfile.web)mtllouer.com
ScraperRailway (Dockerfile.scraper)internal only
AdminRailway (Dockerfile.admin)
DocsCloudflare Pagesdocs.mtllouer.com

All Dockerfiles are multi-stage builds using node:20-alpine (scraper uses Playwright image for browser support).

Dockerfiles

API (Dockerfile.api)

  • Builds @mtl-rent/database + @mtl-rent/email + @mtl-rent/api
  • Runs pnpm db:migrate on startup before starting the server
  • Single-stage build

Web (Dockerfile.web)

  • Multi-stage: builder stage compiles Next.js, runner stage serves standalone output
  • NEXT_PUBLIC_* vars are build args — must be set in Railway service settings as build-time variables, not just runtime
  • Build args: NEXT_PUBLIC_API_URL, NEXT_PUBLIC_GOOGLE_MAPS_API_KEY, NEXT_PUBLIC_IMAGE_CDN_URL

Scraper (Dockerfile.scraper)

  • Based on mcr.microsoft.com/playwright:v1.58.2-noble (needs browsers)
  • Builds database + email + scraper packages
  • On startup: creates scraper.* schema, runs scraper migrations, then starts worker

Admin (Dockerfile.admin)

  • Multi-stage: builds React SPA with Vite, serves via Fastify

Environment Variables

API Service

VariableRequiredDescription
DATABASE_URLYesRailway PostgreSQL connection string
JWT_SECRETYesProduction JWT signing secret
API_PORTNoDefaults to 3001
IMAGE_CDN_URLYeshttps://images.mtllouer.com — API redirects /uploads/* to CDN
R2_ENDPOINTYesCloudflare R2 S3-compatible endpoint
R2_ACCESS_KEY_IDYesR2 access key
R2_SECRET_ACCESS_KEYYesR2 secret key
R2_BUCKETYesR2 bucket name
GOOGLE_MAPS_API_KEYYesServer-side geocoding
RESEND_API_KEYYesTransactional email
RESEND_FROM_EMAILYesSender address
ADMIN_USER / ADMIN_PASSYesAdmin dashboard credentials

CORS allows: mtllouer.com, *.up.railway.app

Web Service (Build Args)

VariableRequiredDescription
NEXT_PUBLIC_API_URLYeshttps://api.mtllouer.com
NEXT_PUBLIC_GOOGLE_MAPS_API_KEYYesGoogle Maps frontend key
NEXT_PUBLIC_IMAGE_CDN_URLYeshttps://images.mtllouer.com

These must be set as build arguments in Railway, not just runtime env vars.

Scraper Service

VariableRequiredDescription
DATABASE_URLYesSame PostgreSQL
AMQP_URLYesRabbitMQ connection (amqp://user:pass@host:5672)
R2_ENDPOINTYesCloudflare R2 S3-compatible endpoint
R2_ACCESS_KEY_IDYesR2 access key
R2_SECRET_ACCESS_KEYYesR2 secret key
R2_BUCKETYesR2 bucket name
IMAGE_STORAGE_PATHNoTemp dir for image processing before R2 upload
KIJIJI_ENABLEDNoDefaults to true
KANGALOU_ENABLEDNoDefaults to false
GOOGLE_MAPS_API_KEYYesGeocoding
TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_IDNoHourly stats notifications

Image Storage (Cloudflare R2)

All images are stored in a Cloudflare R2 bucket served via custom domain images.mtllouer.com. Both the API (landlord uploads) and scraper (scraped images) upload to R2 when R2_* env vars are configured.

Image Flow

Scraper                                    API (landlord upload)
  │                                           │
  ▼                                           ▼
Download from platform                    Receive multipart upload
  │                                           │
  ▼                                           ▼
Process (watermark removal,               Upload to R2 via S3 SDK
resize, optimize via sharp)               (uploads/{uuid}.ext)
  │                                           │
  ▼                                           ▼
Upload to R2 via S3 SDK                   DB: /uploads/{uuid}.ext
(uploads/{uuid}.jpg)                          │
  │                                           ▼
  ▼                                       Frontend resolves via CDN
DB: /uploads/{uuid}.jpg


CDN: images.mtllouer.com/uploads/{uuid}.jpg

Key Details

  • Scraper downloads images to temp local storage, processes them (watermark removal, resize via sharp), then uploads to R2 (services/scraper/src/pipeline/image-downloader.ts)
  • API landlord uploads upload directly to R2 when R2_* env vars are set; falls back to local disk for dev without R2 (services/api/src/routes/images.ts + services/api/src/utils/r2.ts)
  • API image deletion deletes from R2 (or local disk in dev)
  • Frontend resolves image URLs via resolveImageUrl() in services/web/lib/utils.ts — prefixes /uploads/* paths with NEXT_PUBLIC_IMAGE_CDN_URL
  • API serving (GET /uploads/:filename): redirects to {IMAGE_CDN_URL}/uploads/{filename} when IMAGE_CDN_URL is set; serves from disk in dev
  • Without R2 credentials, the API logs a warning on startup — local disk storage is only suitable for development

R2 Bucket Setup

The R2 bucket uses a custom domain (images.mtllouer.com) configured in Cloudflare. Objects are stored with the key pattern uploads/{uuid}.jpg and served with Cache-Control: public, max-age=31536000, immutable.

Database

  • Provider: Railway PostgreSQL
  • Migrations: Run automatically on API container start (pnpm db:migrate && node ...)
  • Scraper schema: Created on scraper container start (scraper.* tables)

Pulling Production Data Locally

bash
pnpm db:pull-prod

This runs pg_dump over SSH to the railway host and restores into the local mtl_rent database. Requires SSH access configured (~/.ssh/config entry for railway).

Deploying

  1. Push to main — Railway auto-deploys all services, Cloudflare Pages auto-deploys docs
  2. Database migrations run automatically on API startup
  3. No manual steps needed for typical code changes

Build-Time Variables

For the Web service, NEXT_PUBLIC_* variables must be set as build arguments in Railway's service settings. They are baked into the Next.js bundle at build time and cannot be changed at runtime.

Local Dev with Production Data

To work locally with production data and images without running scrapers:

bash
# 1. Pull production database
pnpm db:pull-prod

# 2. Set in .env — images served from R2 CDN
NEXT_PUBLIC_IMAGE_CDN_URL=https://images.mtllouer.com
IMAGE_CDN_URL=https://images.mtllouer.com

# 3. Optionally configure R2 for local uploads too
R2_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET=mtl-rent-images

# 4. Disable scrapers (already defaults in .env)
KIJIJI_ENABLED=false
KANGALOU_ENABLED=false

Without R2 credentials locally, landlord image uploads will still work using local disk — but images won't be visible on the CDN.

Docs (Cloudflare Pages)

VitePress docs deploy automatically via Cloudflare Pages on push to main.

Configuration

  • Platform: Cloudflare Pages (Git integration)
  • Root directory: docs/
  • Build command: cd .. && pnpm install --frozen-lockfile && cd docs && npx vitepress build
  • Build output: .vitepress/dist
  • Node version: NODE_VERSION=20
  • Custom domain: docs.mtllouer.com

Access Control

Docs are protected via Cloudflare Zero Trust Access. Only authorized email addresses can view the site (authenticated via email OTP). Configure access policies in the Cloudflare Zero Trust dashboard under Access > Applications.