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.
Sometimes the best learning comes from things that don't work the first time. Deploying Ephemera—a temporary content sharing app—through my self-hosted Coolify instance turned into an iterative debugging session that touched every layer of the deployment stack.
Here's what I learned along the way.
The Goal
Ephemera is a Next.js application with a PostgreSQL backend. 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, and together they paint a picture of what production deployments actually require.
Issue 1: 404 Errors
The first deployment built successfully but returned 404s. The problem was in how 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 accessible from the Next.js container.
Fix: Added PostgreSQL directly to the docker-compose file so both services share the same Docker network. 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
Even with the app and database running, traffic wasn't reaching the container. Traefik couldn't discover the service.
Fix: Added explicit Traefik labels for service discovery. In Coolify's environment, services need labels that tell Traefik how to route traffic:
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 intermittently. Traefik would find the service, then lose it. The issue was container names changing between deployments.
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
With container names stable, Traefik still couldn't reach the service. The containers were running but isolated.
Fix: Added the Coolify network explicitly to allow Traefik to communicate with the application containers:
networks:
default:
external:
name: coolify
Issue 6: Health Check Failures
The deployment passed all checks but the container kept restarting. Docker reported unhealthy status.
Looking at the logs, the health check was making requests to localhost, but Node.js was binding to IPv6 by default. The health check resolved localhost to 127.0.0.1 (IPv4), missing 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 relative import paths and ensured the public directory was included in the Docker build context.
The Debugging Process
Each issue required a different debugging approach:
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 isn't just "containers can talk to each other." It's:
- 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. Test health checks in the same environment they'll 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 aren't decorations—they're the routing configuration. Typos, missing labels, or wrong values mean traffic goes nowhere. Treat them like code: review carefully, test thoroughly.
4. Logs Tell the Story
When something doesn't work:
- Check application logs:
docker logs container - Check Traefik logs:
docker logs traefik - Check Docker events:
docker events - 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.
More importantly, I understand every layer of the deployment now. When something breaks—and it will—I know where to look.
What's Next
This debugging journey highlighted 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
Self-hosting isn't the easy path, but it's the educational one.