Appearance
Deployment
Production runs on Railway with auto-deploy on push to main.
Services
| Service | Platform | Production URL |
|---|---|---|
| API | Railway (Dockerfile.api) | api.mtllouer.com |
| Web | Railway (Dockerfile.web) | mtllouer.com |
| Scraper | Railway (Dockerfile.scraper) | internal only |
| Admin | Railway (Dockerfile.admin) | — |
| Docs | Cloudflare Pages | docs.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:migrateon 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
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | Yes | Railway PostgreSQL connection string |
JWT_SECRET | Yes | Production JWT signing secret |
API_PORT | No | Defaults to 3001 |
IMAGE_CDN_URL | Yes | https://images.mtllouer.com — API redirects /uploads/* to CDN |
R2_ENDPOINT | Yes | Cloudflare R2 S3-compatible endpoint |
R2_ACCESS_KEY_ID | Yes | R2 access key |
R2_SECRET_ACCESS_KEY | Yes | R2 secret key |
R2_BUCKET | Yes | R2 bucket name |
GOOGLE_MAPS_API_KEY | Yes | Server-side geocoding |
RESEND_API_KEY | Yes | Transactional email |
RESEND_FROM_EMAIL | Yes | Sender address |
ADMIN_USER / ADMIN_PASS | Yes | Admin dashboard credentials |
CORS allows: mtllouer.com, *.up.railway.app
Web Service (Build Args)
| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_API_URL | Yes | https://api.mtllouer.com |
NEXT_PUBLIC_GOOGLE_MAPS_API_KEY | Yes | Google Maps frontend key |
NEXT_PUBLIC_IMAGE_CDN_URL | Yes | https://images.mtllouer.com |
These must be set as build arguments in Railway, not just runtime env vars.
Scraper Service
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | Yes | Same PostgreSQL |
AMQP_URL | Yes | RabbitMQ connection (amqp://user:pass@host:5672) |
R2_ENDPOINT | Yes | Cloudflare R2 S3-compatible endpoint |
R2_ACCESS_KEY_ID | Yes | R2 access key |
R2_SECRET_ACCESS_KEY | Yes | R2 secret key |
R2_BUCKET | Yes | R2 bucket name |
IMAGE_STORAGE_PATH | No | Temp dir for image processing before R2 upload |
KIJIJI_ENABLED | No | Defaults to true |
KANGALOU_ENABLED | No | Defaults to false |
GOOGLE_MAPS_API_KEY | Yes | Geocoding |
TELEGRAM_BOT_TOKEN / TELEGRAM_CHAT_ID | No | Hourly 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}.jpgKey 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()inservices/web/lib/utils.ts— prefixes/uploads/*paths withNEXT_PUBLIC_IMAGE_CDN_URL - API serving (
GET /uploads/:filename): redirects to{IMAGE_CDN_URL}/uploads/{filename}whenIMAGE_CDN_URLis 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-prodThis 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
- Push to
main— Railway auto-deploys all services, Cloudflare Pages auto-deploys docs - Database migrations run automatically on API startup
- 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=falseWithout 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.