Back to Blog

Deploying Ephemera with Coolify and Traefik

A deep dive into deploying a Next.js application with PostgreSQL on a self-hosted Coolify instance, troubleshooting Traefik routing, health checks, and Docker networking.

CoolifyTraefikDockerNext.jsPostgreSQLSelf-Hosting

Ephemera is a small app for sharing temporary content, and deploying it through my self-hosted Coolify instance turned into an iterative debugging session that touched every layer of the stack.

The goal

It's a Next.js application with a PostgreSQL backend, and the deployment target was my Coolify instance, which uses Traefik as a reverse proxy. Simple enough in theory: push code, Coolify builds a Docker image, Traefik routes traffic.

Reality was more nuanced.

The deployment journey

What should have been a straightforward deployment turned into twelve pull requests over a day. Each one fixed a different issue. Together they paint a picture of what production deployments actually require.

Issue 1: 404 errors

The first deployment built successfully. Then it returned 404s. The cause was the way Coolify structured its docker-compose files for the deployment.

Fix: Restructured the docker-compose configuration to match what Coolify expected for service discovery.

Issue 2: Database connectivity

With the app running, database connections failed. PostgreSQL wasn't reachable from the Next.js container.

Fix: Added PostgreSQL directly to the docker-compose file so both services share the same Docker network, and the database connection now resolves via Docker's internal DNS.

services:
  app:
    build: .
    depends_on:
      - db
    environment:
      DATABASE_URL: postgres://user:pass@db:5432/ephemera
  
  db:
    image: postgres:16
    volumes:
      - pgdata:/var/lib/postgresql/data

Issue 3: Traefik routing

App up, database up, and still nothing reaching the container. Traefik couldn't discover the service.

Fix: Added explicit Traefik labels for service discovery. In Coolify's environment, a service needs labels that tell Traefik how to route traffic to it:

labels:
  - "traefik.enable=true"
  - "traefik.http.routers.ephemera.rule=Host(`ephemera.isaacurman.com`)"
  - "traefik.http.services.ephemera.loadbalancer.server.port=3000"

Issue 4: Container name stability

Routing worked, but only sometimes. Because container names changed between deployments, Traefik would find the service and then lose it.

Fix: Added a fixed container_name in the docker-compose file. This gives Traefik a stable target:

services:
  app:
    container_name: ephemera-app

Issue 5: Network configuration

Names were stable and Traefik still couldn't reach the service. The containers were running but isolated.

Fix: Added the Coolify network explicitly to let Traefik communicate with the application containers:

networks:
  default:
    external:
      name: coolify

Issue 6: Health check failures

The deployment passed all its checks and the container kept restarting anyway, with Docker reporting unhealthy status. The logs explained it. The health check was making requests to localhost and resolving it to 127.0.0.1 (IPv4), while Node.js was binding to IPv6 by default, so the check missed the server entirely.

Fix: Updated the Next.js configuration to explicitly bind to 0.0.0.0:

// next.config.ts
const config = {
  experimental: {
    serverActions: {
      bodySizeLimit: '2mb',
    },
  },
  output: 'standalone',
};

And in the Dockerfile:

ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

Issue 7: Build failures

With networking sorted, builds started failing. Import paths that worked locally didn't resolve in the Docker build context.

Fix: Corrected the relative import paths and made sure the public directory was included in the Docker build context.

The debugging process

Each kind of failure needed its own way in.

For network issues

# Check if container is on the right network
docker network inspect coolify

# Test connectivity from Traefik container
docker exec traefik wget -qO- http://ephemera-app:3000/api/health

For health check issues

# Check health status
docker inspect ephemera-app | jq '.[0].State.Health'

# View health check logs
docker inspect ephemera-app | jq '.[0].State.Health.Log'

For routing issues

# Check Traefik's view of services
curl http://localhost:8080/api/http/services

# View Traefik logs for routing decisions
docker logs traefik 2>&1 | grep ephemera

Lessons learned

1. Container networking is its own domain

Docker networking is four separate things:

  • Named networks with explicit membership
  • DNS resolution within networks
  • Port exposure vs. port publishing
  • IPv4 vs. IPv6 binding

Each of these can break deployments in subtle ways.

2. Health checks need testing

A health check that works locally might fail in production, so test it in the same environment it will run in:

# Don't just test from the host
docker exec container curl http://localhost:3000/health

# Test what Docker's health check actually does
docker exec container sh -c 'wget -qO- http://127.0.0.1:3000/health'

3. Labels are configuration

Traefik labels are the routing configuration. A typo, a missing label, or a wrong value means traffic goes nowhere. Treat them like code: review them, test them.

4. Logs tell the story

When something doesn't work:

  1. Check application logs: docker logs container
  2. Check Traefik logs: docker logs traefik
  3. Check Docker events: docker events
  4. Check health status: docker inspect container

The answer is usually in there somewhere.

The result

After twelve iterations, Ephemera runs reliably on my self-hosted infrastructure: push to main, Coolify rebuilds, Traefik routes traffic, and users can create ephemeral content that automatically expires.

I understand every layer of the deployment now. When something breaks (and it will), I know where to look.

What's next

The debugging exposed gaps in my observability setup. Next steps:

  • Add structured logging for easier debugging
  • Set up alerting for health check failures
  • Document the deployment configuration for future reference

Twelve pull requests is an expensive way to learn a deployment stack. I would pay it again for knowing exactly why every line of that compose file is there.