Skip to main content

What We Learned

Railway’s automatic build detection (Railpack) assumes a single service per repository. When deploying multiple services from a monorepo, Railpack fails to detect any language and errors with “no supported languages found.” We discovered a simple but powerful pattern: create a root-level Dockerfile that acts as a proxy, navigating to the correct service subdirectory and building only that service.

The Problem

Railway’s Railpack scans a repository’s root directory for build indicators:
  • go.mod → Go project
  • package.json → Node.js project
  • Dockerfile → Use Docker build
  • etc.
In a monorepo, these files exist in subdirectories, not at the root:
nestr-tools/                # Railpack scans HERE
├── engine/go.mod          # Railpack doesn't find this
├── olly/go.mod            # Railpack doesn't find this
├── web/package.json       # Railpack doesn't find this
Result: Railpack finds nothing, fails detection.

The Solution: Root Dockerfile Proxy

Create a single Dockerfile at the monorepo root that:
  1. Copies the entire monorepo
  2. Navigates (WORKDIR) to the specific service subdirectory
  3. Builds only that service
  4. Produces a minimal image with only that service’s binary
# /Dockerfile at monorepo root
FROM golang:1.25-alpine AS builder
WORKDIR /build
COPY . .              # Copy entire monorepo

WORKDIR /build/engine # Navigate to specific service
RUN go mod download
RUN go build -o nestr .

FROM alpine:latest
COPY --from=builder /build/engine/nestr /app/
EXPOSE 8080
CMD ["/app/nestr", "serve"]
Why This Works:
  • ✅ Railpack detects Dockerfile at root → triggers Docker build
  • ✅ Docker context includes entire monorepo (no submodule issues)
  • ✅ WORKDIR navigation isolates build to one service
  • ✅ Final image contains only that service’s binary (minimal)
  • ✅ Same pattern reusable for all services (just change WORKDIR)

Key Insights

1. One Dockerfile Per Service, Deployed Via One Root Dockerfile

Instead of maintaining three Dockerfiles (one per service), we maintain:
  • One generic root Dockerfile (shared template)
  • Three service-specific railway.toml files (small configs)
The root Dockerfile is intelligent enough to build any service:
# Can build engine, olly, or web—whoever sets SERVICE env var
ARG SERVICE=engine
WORKDIR /build/${SERVICE}

2. Railway Configuration Is Minimal

Each service only needs SERVICE/railway.toml:
[build]
builder = "dockerfile"
dockerfilePath = "Dockerfile"

[deploy]
healthcheckPath = "/health"

[service]
internalPort = 8080
No complex build scripts or conditional logic needed.

3. Environment Variables Bridge the Deployment Gap

Services deployed to Railway need to know each other’s public URLs:
// ✅ Correct: Use env var with local fallback
ollyURL := os.Getenv("OLLY_PUBLIC_URL")
if ollyURL == "" {
    ollyURL = "http://localhost:8090"  // Local dev default
}

// ❌ Wrong: Hard-code URLs
client := http.NewClient("http://localhost:8090")
In Railway UI, set:
olly service:
  RAILWAY_PUBLIC_DOMAIN=olly-production-xxxxx.up.railway.app

web service:
  OLLY_PUBLIC_URL=https://olly-production-xxxxx.up.railway.app
  ENGINE_PUBLIC_URL=https://engine-production-xxxxx.up.railway.app

4. Build Failures Are Easy to Diagnose

When deploy fails, the error message is unambiguous:
  • ❌ “Railpack could not determine language” → Missing root Dockerfile
  • ❌ “go: github.com/sibling/package not found” → Cross-repo import issue
  • ❌ “health check timeout” → Service doesn’t listen or health endpoint missing
vs. the previous experience where Railpack silently tried Node.js, failed, tried Python, failed, etc.

5. Multi-Stage Builds Are Essential

Without multi-stage builds, final Docker images are enormous (500MB+). With multi-stage:
# Stage 1: Builder (includes Go SDK, build tools, dependencies)
FROM golang:1.25-alpine AS builder
# ... 500MB layer (discarded)

# Stage 2: Runtime (only the binary)
FROM alpine:latest
COPY --from=builder /build/engine/nestr /app/
# Final image: 20-50MB
Impact: 10x smaller images = faster deploys, lower costs, faster cold starts.

Metrics

MetricBeforeAfter
Deploy detectionRailpack tries 8+ languages, failsImmediate Dockerfile detection
Dockerfile maintenance3 files (one per service)1 file (root) + 3 configs (service-specific)
Image size per service500MB+20-50MB
Deploy time10-15 min (build + push + start)2-5 min
Bandwidth (CD pipeline)1.5GB per deploy100-200MB per deploy

Troubleshooting Patterns Discovered

  1. Health Check Timeout → Service listens on wrong port or wrong path
    • Fix: Add env var for PORT, default to 8080 in code
  2. Service Can’t Reach Other Services → Hard-coded localhost URLs
    • Fix: Use public HTTPS URLs from Railway domains, not internal networking
  3. Build Fails Mysteriously → Working directory issue
    • Fix: Use WORKDIR navigation carefully; verify go.mod path in logs
  4. Two Services Deploy to Same Port → Config collision
    • Fix: Each service gets unique internal port (8080, 8090, 3000, etc.)

Unexpected Benefits

  • No Monorepo Lock-In: Services can be extracted to separate repos later (just remove the root Dockerfile)
  • Faster Iteration: Railway redeploy for one service takes 5 min, not 15 (doesn’t rebuild everything)
  • Clear Ownership: Each team owns their service directory + their railway.toml
  • Easy Onboarding: New engineers see clear structure: “engine/ builds as Docker image, olly/ builds as Docker image”

Comparison: Other Approaches (Why This Won **)“

ApproachProsCons
Root Dockerfile Proxy (ours)Simple, clean, Railway-nativeCopies entire monorepo to Docker context
Git submodulesServices stay independentGit complexity, slow clones
Separate reposFully independentMicroservices operational overhead
Monorepo subdirectory config (railway.json)Config-firstRailway doesn’t reliably read it
Custom build scriptsFlexibleMaintenance burden, harder to debug

What’s Next for Teams Adopting This

  1. Create root Dockerfile with service template
  2. Add service-specific railway.toml files
  3. Set up environment variables in Railway UI (map inter-service URLs)
  4. Document the pattern in team wiki (copy our checklist)
  5. Add to CI/CD: enforce Dockerfile exists at root before merging

Authored by: OpenCode (Claude Haiku 4-5)
Session Date: 2026-03-30
Context: Nestr Engine, Olly, and Web deployment to Railway
Confidence: High (3/3 services successfully deployed and healthy)