Self-hosting Rhesis with Docker compose: Our journey to a one-command setup

Asad Miah
November 4, 2025
11 mins

The "Quick Win" that wasn't

It started with a simple question from our first Objectives & Roadmap session: "Can I run Rhesis on my laptop without dealing with cloud credentials?"

"Of course," I thought. "We already have Dockerfiles for every service. Just throw them in a docker-compose.yml and call it a day. Should take an afternoon."

Spoiler: It took three weeks.

And not because Docker Compose is hard, but because making a multi-service Gen AI platform work locally exposed assumptions we didn't even know we'd made.

The pain: When production thinking meets local reality

Our production setup was beautiful. Backend talking to Cloud SQL. Worker pulling from Redis. Frontend calling APIs. Everything is running in GCP Cloud Run and Cloud SQL, and traffic is handled by load balancers. It just worked.

Figure 1: Architectural Diagram of Rhesis Component

We then attempted to run it all on a single laptop.

Problem 1: The Next.js/FastAPI cross-container communication nightmare

This was the most challenging problem we faced, and it took us nearly a week to solve properly.

The issue?

Next.js is a hybrid framework. Half of it runs server-side (in the Next.js container), and half runs client-side (in your browser). They need to talk to the same FastAPI backend, but they're in completely different network contexts.

Here's what was happening:

Code Block
apps/frontend/src/utils/url-resolver.ts
// apps/frontend/src/utils/url-resolver.ts

export function getBaseUrl(): string {
  if (typeof window === 'undefined') {
    // Server-side: use BACKEND_URL for container-to-container communication
    return process.env.BACKEND_URL || '';
  } else {
    // Client-side: use NEXT_PUBLIC_API_BASE_URL for browser-to-host communication
    return process.env.NEXT_PUBLIC_API_BASE_URL || '';
  }
}


We'd fix it for the browser, and server-side rendering would break. We'd fix server-side rendering, and client-side API calls would fail. It felt like playing whack-a-mole with network requests.

Our initial attempts were... creative:

Attempt 1: Use localhost:8080 everywhere. Result: Server-side rendering failed silently. Pages would work on refresh (client-side) but break on first load (server-side).

Attempt 2: Use backend:8080 everywhere. Result: The browser was unable to resolve the hostname. Dev tools full of ERR_NAME_NOT_RESOLVED errors.

Attempt 3: Use host.docker.internal. Result: This special Docker hostname felt like a hacky workaround, added extra complexity to our configuration, and we weren't confident it would work reliably across all environments.

We needed a solution that was clean, explicit, and worked for both contexts without special Docker magic.

Problem 2: The environment variable explosion

Each service had its own .env file. Seven environment variables for database config. Five more for Redis. Twelve authentication variables (Auth0, JWT, NextAuth) scattered across different files. When something didn't work, we played "guess which service has the wrong REDIS_URL" for hours.

Our first Docker Compose file looked like this:

Code Block
docker-compose.yml
Backend:
  environment:
    - SQLALCHEMY_DATABASE_URL=${SQLALCHEMY_DATABASE_URL}
    - SQLALCHEMY_DB_DRIVER=${SQLALCHEMY_DB_DRIVER}
    - SQLALCHEMY_DB_PORT=${SQLALCHEMY_DB_PORT}
    # ... 30 more lines of this

Copy-paste hell. And every time we updated one service, we'd forget to update another.

Problem 3: The port conflict wars

Five services. Five different ideas about which port to use. The backend wanted 8080. So did the worker's health check. The docs site? Also 8080. It was like musical chairs, but for TCP ports, and nobody was playing music.

Local development would randomly fail with "port already in use" errors. We'd hunt down processes with lsof, kill them, restart the containers, and hope for the best.

Problem 4: The startup race condition

The backend would start before PostgreSQL was ready. The worker would crash because Redis hadn't initialized. The frontend would make API calls to a backend that didn't exist yet.

We tried sleep commands. We tried restart policies. We tried our best, but nothing worked consistently.

What we tried (and what actually worked)

Attempt 1: The "just use Docker" approach

Our first instinct was to keep it simple, with a minimal configuration, just to get things running.

Code Block
Terminal
docker-compose up

It failed immediately. Environment variables were missing. Services couldn't find each other. We had five containers that might as well have been on different planets.

The lesson: Docker Compose isn't magic. It needs to understand how your services interact with each other.

Attempt 2: YAML anchors (thegame changer)

Then someone on the team remembered YAML anchors. You know, those &anchor and *alias things you see in examples but never actually use?

Turns out they're perfect for this. We created reusable config blocks:

Code Block
docker-compose.yml
x-common-database: &common-database
  SQLALCHEMY_DATABASE_URL: ${SQLALCHEMY_DATABASE_URL}
  SQLALCHEMY_DB_DRIVER: ${SQLALCHEMY_DB_DRIVER:-postgresql}
  SQLALCHEMY_DB_PORT: ${SQLALCHEMY_DB_PORT:-5432}
  SQLALCHEMY_DB_USER: ${SQLALCHEMY_DB_USER}
  SQLALCHEMY_DB_PASS: ${SQLALCHEMY_DB_PASS}
  SQLALCHEMY_DB_HOST: ${SQLALCHEMY_DB_HOST:-postgres}
  SQLALCHEMY_DB_NAME: ${SQLALCHEMY_DB_NAME}

x-common-redis: &common-redis
  REDIS_URL: ${REDIS_URL:-redis://:${REDIS_PASSWORD}@redis:6379/0}
  BROKER_URL: ${BROKER_URL:-redis://:${REDIS_PASSWORD}@redis:6379/0}

Now our service definitions looked like this:

Code Block
docker-compose.yml
backend:
  environment:
    <<: [*common-database, *common-redis, *common-auth0]


One change, multiple services updated. Configuration became a design pattern instead of a chore.

The lesson: When you find yourself copy-pasting, there's probably a better way.

Attempt 3: The dual-URL strategy (solving cross-container communication)

This was the breakthrough moment for the Next.js/FastAPI communication problem.

The insight: Next.js requires two distinct URLs, depending on where the code is running. We created a utility that detects the execution context:

Code Block
apps/frontend/src/utils/url-resolver.ts
// apps/frontend/src/utils/url-resolver.ts

export function getBaseUrl(): string {
  if (typeof window === 'undefined') {
    // Server-side: use BACKEND_URL for container-to-container communication
    return process.env.BACKEND_URL || 'http://backend:8080';
  } else {
    // Client-side: use NEXT_PUBLIC_API_BASE_URL for browser-to-host communication
    return process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
  }
}

Now, when Next.js server-side rendering calls the API, it uses http://backend:8080 (the Docker service name). When browser JavaScript calls the API, it uses http://localhost:8080 (accessible from outside Docker).

But we didn't stop there. We also added Next.js rewrites to make the routing seamless:

Code Block
next.config.mjs
// next.config.mjs

async rewrites() {
  const backendUrl = process.env.BACKEND_URL || '';
  return [
    {
      source: '/api/:path*',
      destination: `${backendUrl}/:path*`,
    },
  ];
}


Now the frontend can call /api/users (a relative URL), and Next.js automatically routes it to the correct backend depending on context. No hardcoded URLs in application code.

In docker-compose.yml, we configure both URLs:

Code Block
docker-compose.yml
frontend:
  environment:
    # For server-side calls (container-to-container)
    BACKEND_URL: http://backend:8080
    # For client-side calls (browser-to-host)
    NEXT_PUBLIC_API_BASE_URL: http://localhost:8080

Bonus fix: We also discovered that macOS resolves localhost to IPv6 (::1) by default, but many services only listen on IPv4 (127.0.0.1). Our URL resolver automatically converts localhost to 127.0.0.1 to avoid this issue across all platforms.

The lesson: Next.js hybrid rendering is powerful, but it requires thinking about network topology from two perspectives simultaneously. Once we embraced that, the solution became obvious.

Attempt 4: Health checks (finally, proper dependencies)

We stopped letting services race to startup and actually implemented proper health checks:

Code Block
docker-compose.yml
postgres:
  healthcheck:
    test: ["CMD-SHELL", "pg_isready -U ${SQLALCHEMY_DB_USER}"]
    interval: 10s
    timeout: 5s
    retries: 5

backend:
  depends_on:
    postgres:
      condition: service_healthy

The backend would wait for PostgreSQL to be ready. The frontend would wait for the backend. Suddenly, docker-compose up became reliable.

The lesson: Don't assume services will be ready just because they've started.

Attempt 5: Organized configuration (sensible defaults where it matters)

We couldn't eliminate all environment variables—auth credentials and encryption keys are genuinely required. But we could organize them intelligently.

We restructured the .env.example into three clear categories:

Code Block
.env
# 🔧 USER-DEFINED (Required) - You MUST set these
# Things like AUTH0_DOMAIN, JWT_SECRET_KEY, DB_ENCRYPTION_KEY

# ⚙️ OPTIONAL (Recommended) - Enhanced functionality
# Things like AZURE_OPENAI_API_KEY, SMTP_HOST

# 🏗️ RHESIS-DEFINED (Don't change) - Internal configuration
# Things like database URLs, Redis connections, service ports

For the RHESIS-DEFINED variables, we added sensible defaults:

Code Block
docker-compose.yml
REDIS_URL: ${REDIS_URL:-redis://:rhesis-redis-pass@redis:6379/0}
SQLALCHEMY_DB_HOST: ${SQLALCHEMY_DB_HOST:-postgres}
BACKEND_URL: ${BACKEND_URL:-http://backend:8080}

Now developers only need to focus on the required credentials. Everything else has smart defaults that work out of the box for local development. Want to customize? Override it. Don't care? It works.

The lesson: You can't eliminate configuration, but you can make it obvious which parts actually need attention.

The Aha moments

The browser isn't inside your container network

The biggest "aha" moment was understanding that there are actually three network contexts, not two:

  1. Container-to-container: Backend → Postgres uses postgres:5432
  2. Server-side Next.js → Backend: Uses backend:8080 (both in Docker)
  3. Browser → Backend: Uses localhost:8080 (browser is on host machine)
  4. We had been considering containers versus hosts, but Next.js introduced a third context that required special handling. The solution wasn't just "use the right URL," it was building infrastructure that automatically picks the right URL based on execution context.

Service names are DNS names

This seems obvious in retrospect, but it took us embarrassingly long to realize: In Docker Compose, service names become DNS hostnames.

When the backend needs Redis, it doesn't connect to localhost:6379. It connects to redis:6379. The service name is the hostname.

Once we internalized this, all our networking issues disappeared.

Volume mounts for hot reload

We wanted local development to be fast, change code, see results, no rebuilds.

Code Block
docker-compose.yml
backend:
  volumes:
    - ./apps/backend/src:/app/src
    - ./sdk:/app/sdk

Now changes to the backend or SDK are instantly available inside the container. No more "build-test-repeat" cycles eating up hours.

Restart policies matter

Code Block
docker-compose.yml
restart: unless-stopped

This one line made development so much more pleasant. Container crash? It restarts. Machine reboots? Services come back up. It's the difference between Docker Compose being a toy and being a serious local development environment.

Key learnings

After three weeks of iteration, here's what stuck with us:

  • Hybrid frameworks need hybrid networking strategies. Next.js taught us that server-side rendering and client-side JavaScript live in different network worlds. You can't use the same URL for both; you need to detect the context and route accordingly. This pattern applies to any SSR framework (Nuxt, SvelteKit, Remix, etc.) in a containerized environment.
  • Docker service names ≠ external hostnames. Inside the Docker network, backend resolves to the backend container. Outside Docker (like in a browser), it doesn't exist. This seems obvious in retrospect, but it's a common pitfall that can waste hours of debugging.
  • Configuration reuse is worth the YAML syntax. YAML anchors feel weird at first, but they're a superpower once you embrace them. Our Docker Compose.yml went from 500 to 240 lines.
  • Health checks aren't optional. If your services depend on each other, health checks make the difference between "it works sometimes" and "it always works."
  • Defaults > Documentation. We could have written a 10-page guide on environment variables. Instead, we established sensible defaults and allowed people to override them when needed. Less docs to maintain, fewer support questions.
  • Platform differences matter. macOS's IPv6-first localhost resolution broke things that worked fine on Linux. Building cross-platform solutions means testing on multiple platforms or building abstractions that handle the differences automatically.
  • Local development should feel like production. The architecture in Docker Compose mirrors our Kubernetes setup—same services, same relationships, same networking patterns. When something works locally, we trust it'll work in production.
  • The best deployment is the one people actually use. Before Docker Compose, spinning up Rhesis locally required a PhD in our infrastructure. Now? Four commands. That accessibility has led to better bug reports, more contributions, and stronger community trust.

What's still in progress

We're not done. There are challenges we're still figuring out:

Secrets Management: Currently, we utilize .env files for local development. It works, but it's not great for teams. How do you securely share credentials without committing them to Git? We're exploring solutions like pass, 1password-cli, and Docker secrets, but haven't landed on "the way" yet.

Resource Limits: Docker Compose can eat all your RAM if you let it. We've added some basic limits, but tuning them for different machine specs is still manual. A 2020 MacBook Air shouldn't have the same limits as a 2024 M3 Max.

Multi-Platform Builds: ARM vs x86, macOS vs Linux container images can behave differently. We're working on improving CI testing across platforms so that the "works on my machine" problem is truly resolved.

Observability: Running locally is great until something breaks and you have no idea which container is misbehaving. We have health checks, but proper logging, tracing, and metrics for local development are still on the roadmap.

Try it yourself

Want to run Rhesis locally? It's genuinely this simple now:

Code Block
Terminal
git clone https://github.com/rhesis-ai/rhesis.git
cd rhesis
cp .env.example .env.docker

# Set required USER-DEFINED variables (minimum for basic operation)

docker compose --env-file .env.docker up


Visit http://localhost:3000 and you're running the full platform frontend, backend, worker, PostgreSQL, Redis, and docs all talking to each other, all on your laptop.

If you encounter any issues (or have ideas for improvement), we'd love to hear from you. Join the Rhesis Discord and let us know what worked, what didn't, and what you'd like to see next.

The bigger picture

Getting Rhesis to run locally wasn't just about Docker Compose; it also involved setting up a local environment. It was about accessibility. About making it possible for anyone, whether you're a Fortune 500 enterprise evaluating our platform or a solo developer experimenting with Gen AI testing to run Rhesis without friction.

It's about trust. When you can run our entire platform on your machine, inspect the code, poke at the APIs, and understand how it works, that's transparency. That's open source done right.

And honestly? It's made us better developers. When setting up locally takes five minutes instead of five hours, we iterate faster. We test more. We break less.

If you're building a multi-service platform and don't have a one-command local setup yet, I highly recommend it. In the future, you (and your contributors) will thank you.

Want to learn more about Rhesis? Check out app.rhesis.ai or dive into the docs at docs.rhesis.ai.

Have questions about our Docker Compose setup? The full configuration is open-source. Feel free to use it, fork it, or learn from it.

Want to contribute? We'd love your help. Whether it's fixing a typo, adding a feature, or improving our Docker setup, all contributions are welcome. Start with our guide.

Share this post
Asad Miah
November 4, 2025
11 mins