DEV Community

Amir Raouf
Amir Raouf

Posted on

Your Team Deserves Better Than a Shared Dev Environment - Ephemeral Environments with Coolify

How I Discovered Coolify (And Why I'm Never Going Back)

I discovered Coolify through a LinkedIn post and decided to give it a shot. Within days, I'd migrated all my side projects to it. Eventually, I even built a Python script to automate spinning apps up and down on my Coolify instance. Now, I'm bringing this workflow to my day job—here's how we're using Coolify for ephemeral preview environments at a 4-6 person dev team.

Coolify struck the perfect balance: a clean UI that didn't hide the Docker underneath, making it trivial to trace logs, change environment variables, or deploy preset apps with a few clicks. Best of all, it's completely open-source, so I wasn't locked into someone else's platform or proprietary magic.

Building Ephemeral Preview Environments: The Practical Guide

Let's cut to the chase. This setup is perfect for startups and small dev teams (5-6 developers) who:

Don't have dedicated DevOps engineers
Can't justify $200+/month for Vercel/Railway
Need isolated testing but don't need Kubernetes complexity
Want to move fast without breaking things

What We're Building

The Goal: Every pull request automatically gets its own complete environment:

  • Isolated frontend + backend + database
  • Unique URL: pr-123.yourdomain.com
  • Automatic SSL certificates
  • Auto-cleanup when PR merges
  • Total cost: $12-40/month (scales with team size)

What You'll Need:

  • AWS Lightsail instance or Hetzner instance or whatever
  • Coolify installed (free, open-source)
  • Bitbucket/GitHub repository
  • Wildcard DNS record
  • 2-3 hours for initial setup

The Stack:

GitHub Repository
    ↓ (GitHub Actions on PR)
Coolify API
    ↓ (orchestrates deployment)
Docker Compose
    ├─ FastAPI (backend)
    ├─ Celery Worker
    ├─ React (frontend)
    └─ PostgreSQL
    ↓ (Traefik routes traffic)
pr-123.yourdomain.com (with SSL)
Enter fullscreen mode Exit fullscreen mode

The Flow

Developer creates PR → GitHub Action triggers
    ↓
Coolify receives deployment request
    ↓
Spins up Docker Compose stack
    ↓
Runs Alembic migrations + seeders
    ↓
Traefik maps containers to pr-123.yourdomain.com
    ↓
Preview ready! (3-5 minutes)
    ↓
PR merged/closed → Auto cleanup
Enter fullscreen mode Exit fullscreen mode

Now let's build it.

Prerequisites

What You Need:

Infrastructure

  • AWS account (for Lightsail)
  • Domain name with DNS access
  • SSH access to servers

Code Repository

  • GitHub repository with Actions enabled
  • Docker Compose file (already done)
  • Dockerfiles for services (already done)
  • Alembic migrations setup

Knowledge

  • Basic Docker understanding
  • GitHub Actions basics
  • Linux command line comfort

Assumed Done:

✅ Dockerfiles for FastAPI, React, Celery worker
✅ docker-compose.yml configured
✅ Alembic migrations folder structure
✅ Database seeder scripts

Step 1: AWS Lightsail Setup

We'll keep AWS setup brief since you likely know this already.

1.1 Create Lightsail Instance

Choose your tier based on team size:

Team Size Instance vCPU RAM Storage Price Concurrent PRs
2-3 devs $12/mo 1 2 GB 40 GB $12 2-3
4-5 devs $24/mo 2 4 GB 80 GB $24 4-5
6+ devs $40/mo 4 8 GB 160 GB $40 6-8

Quick Setup:

# 1. Launch instance with Ubuntu 22.04 or 24.04
# 2. Add your SSH key
# 3. Open ports in firewall:
#    - 22 (SSH)
#    - 80 (HTTP)
#    - 443 (HTTPS)
#    - 8000 (Coolify UI)
# 4. Note your instance IP address
Enter fullscreen mode Exit fullscreen mode

1.2 Configure DNS

Set up wildcard DNS for preview environments:

# In your DNS provider (Cloudflare, Route53, etc.)
*.yourdomain.com    A    <lightsail-ip>
coolify.yourdomain.com    A    <lightsail-ip>
Enter fullscreen mode Exit fullscreen mode

Why wildcard?

  • Allows automatic URLs: pr-123.yourdomain.com, pr-456.yourdomain.com
  • Coolify generates these dynamically
  • No manual DNS changes per preview

Step 2: Install Coolify

SSH into your Lightsail instance and install Coolify.

2.1 Run Installation Script

# SSH into instance
ssh ubuntu@

# Install Coolify (takes ~5 minutes)
curl -fsSL https://cdn.coollabs.io/coolify/install.sh | bash

# Installation will:
# - Install Docker & Docker Compose
# - Set up Coolify containers
# - Configure Traefik reverse proxy
# - Generate SSL certificates
Enter fullscreen mode Exit fullscreen mode

2.2 Access Coolify UI

# Navigate to:
http://:8000

# Or (if DNS is configured):
https://coolify.yourdomain.com
Enter fullscreen mode Exit fullscreen mode

2.3 Initial Setup

  1. Create admin account

    • Set strong password
    • Save credentials securely
  2. Configure server

    • Coolify auto-detects localhost
    • Server should show as "Connected"
  3. Generate API token

    • Go to: Settings → API Tokens
    • Click "Create Token"
    • Copy token (you'll need this for GitHub Actions)
    • Store securely (treat like a password)

Step 3: Configure Your Application

3.1 Project Structure

Your repository should look like this:

your-repo/
├── backend/
│   ├── Dockerfile
│   ├── app/
│   ├── alembic/
│   │   ├── versions/
│   │   └── env.py
│   └── requirements.txt
├── frontend/
│   ├── Dockerfile
│   ├── src/
│   └── package.json
├── worker/
│   ├── Dockerfile (or shares backend Dockerfile)
│   └── tasks.py
├── db/
│   └── seeders/
│       └── seed_data.py
├── docker-compose.yml
└── .github/
    └── workflows/
        └── preview.yml
Enter fullscreen mode Exit fullscreen mode

3.2 Docker Compose Configuration

Your docker-compose.yml (already done, but here's what Coolify needs):

version: '3.8'

services:
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile
    environment:
      - DATABASE_URL=postgresql://user:${DB_PASSWORD}@db:5432/appdb
      - REDIS_URL=redis://redis:6379/0
      - ENVIRONMENT=${ENVIRONMENT:-preview}
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy
    networks:
      - app-network
    labels:
      # Coolify uses these labels
      - "traefik.enable=true"
      - "traefik.http.routers.backend-${PR_NUMBER}.rule=Host(`pr-${PR_NUMBER}-api.yourdomain.com`)"
      - "traefik.http.services.backend-${PR_NUMBER}.loadbalancer.server.port=8000"

  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
      args:
        - REACT_APP_API_URL=https://pr-${PR_NUMBER}-api.yourdomain.com
    ports:
      - "3000:80"
    networks:
      - app-network
    labels:
      - "traefik.enable=true"
      - "traefik.http.routers.frontend-${PR_NUMBER}.rule=Host(`pr-${PR_NUMBER}.yourdomain.com`)"
      - "traefik.http.services.frontend-${PR_NUMBER}.loadbalancer.server.port=80"

  worker:
    build:
      context: ./backend
      dockerfile: Dockerfile
    command: celery -A tasks worker --loglevel=info
    environment:
      - DATABASE_URL=postgresql://user:${DB_PASSWORD}@db:5432/appdb
      - REDIS_URL=redis://redis:6379/0
    depends_on:
      - db
      - redis
    networks:
      - app-network

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=user
      - POSTGRES_PASSWORD=${DB_PASSWORD}
      - POSTGRES_DB=appdb
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - app-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U user -d appdb"]
      interval: 10s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    networks:
      - app-network

volumes:
  postgres_data:

networks:
  app-network:
    driver: bridge
Enter fullscreen mode Exit fullscreen mode

Key Points:

  • ${PR_NUMBER} will be injected by GitHub Actions
  • ${DB_PASSWORD} set in Coolify environment variables
  • Traefik labels handle automatic routing
  • Health checks ensure database is ready before migrations

Step 4: GitHub Actions Integration

4.1 Add Secrets to GitHub

Go to: Repository → Settings → Secrets and variables → Actions

Add these secrets:

COOLIFY_TOKEN         = <your-coolify-api-token>
COOLIFY_URL           = https://coolify.yourdomain.com
LIGHTSAIL_HOST        = <lightsail-ip>
LIGHTSAIL_SSH_KEY     = <private-ssh-key>
DB_PASSWORD           = <database-password>
Enter fullscreen mode Exit fullscreen mode

4.2 Create GitHub Actions Workflow

Create .github/workflows/preview.yml:

name: Preview Environment

on:
  pull_request:
    types: [opened, synchronize, reopened]
    branches:
      - main

env:
  PR_NUMBER: ${{ github.event.pull_request.number }}
  BRANCH_NAME: ${{ github.head_ref }}

jobs:
  deploy-preview:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up SSH
        uses: webfactory/ssh-agent@v0.8.0
        with:
          ssh-private-key: ${{ secrets.LIGHTSAIL_SSH_KEY }}

      - name: Deploy to Coolify
        env:
          COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }}
          COOLIFY_URL: ${{ secrets.COOLIFY_URL }}
          DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
          HOST: ${{ secrets.LIGHTSAIL_HOST }}
        run: |
          # Create deployment directory on server
          ssh -o StrictHostKeyChecking=no ubuntu@$HOST "mkdir -p /opt/previews/pr-${PR_NUMBER}"

          # Copy repository to server
          rsync -avz --exclude 'node_modules' --exclude '.git' \
            ./ ubuntu@$HOST:/opt/previews/pr-${PR_NUMBER}/

          # Deploy via Coolify API
          ssh ubuntu@$HOST "cd /opt/previews/pr-${PR_NUMBER} && \
            export PR_NUMBER=${PR_NUMBER} && \
            export DB_PASSWORD=${DB_PASSWORD} && \
            docker-compose -p preview-pr-${PR_NUMBER} up -d --build"

      - name: Run Database Migrations
        env:
          HOST: ${{ secrets.LIGHTSAIL_HOST }}
        run: |
          ssh ubuntu@$HOST "docker exec preview-pr-${PR_NUMBER}-backend-1 \
            alembic upgrade head"

      - name: Seed Database
        env:
          HOST: ${{ secrets.LIGHTSAIL_HOST }}
        run: |
          ssh ubuntu@$HOST "docker exec preview-pr-${PR_NUMBER}-backend-1 \
            python db/seeders/seed_data.py"

      - name: Wait for Services
        run: sleep 30

      - name: Comment PR with Preview URL
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const prNumber = context.payload.pull_request.number;
            const comment = `## 🚀 Preview Environment Ready!

            **Frontend:** https://pr-${prNumber}.yourdomain.com
            **API:** https://pr-${prNumber}-api.yourdomain.com

            *Last updated: ${new Date().toISOString()}*
            `;

            github.rest.issues.createComment({
              ...context.repo,
              issue_number: prNumber,
              body: comment
            });
Enter fullscreen mode Exit fullscreen mode

4.3 Cleanup Workflow

Create .github/workflows/cleanup-preview.yml:

name: Cleanup Preview Environment

on:
  pull_request:
    types: [closed]
    branches:
      - main

env:
  PR_NUMBER: ${{ github.event.pull_request.number }}

jobs:
  cleanup:
    runs-on: ubuntu-latest
    steps:
      - name: Set up SSH
        uses: webfactory/ssh-agent@v0.8.0
        with:
          ssh-private-key: ${{ secrets.LIGHTSAIL_SSH_KEY }}

      - name: Stop and Remove Containers
        env:
          HOST: ${{ secrets.LIGHTSAIL_HOST }}
        run: |
          ssh -o StrictHostKeyChecking=no ubuntu@$HOST "\
            cd /opt/previews/pr-${PR_NUMBER} && \
            docker-compose -p preview-pr-${PR_NUMBER} down -v && \
            cd .. && \
            rm -rf pr-${PR_NUMBER}"

      - name: Comment PR
        uses: actions/github-script@v7
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const prNumber = context.payload.pull_request.number;
            github.rest.issues.createComment({
              ...context.repo,
              issue_number: prNumber,
              body: `## 🧹 Preview Environment Cleaned Up\n\nResources freed for PR #${prNumber}`
            });
Enter fullscreen mode Exit fullscreen mode

Step 5: Traefik Port Mapping

Coolify uses Traefik as its reverse proxy to handle routing and SSL.

5.1 How Traefik Works

External Request
    ↓
https://pr-123.yourdomain.com
    ↓
DNS resolves to Lightsail IP
    ↓
Traefik (listening on :80/:443)
    ↓
Reads Traefik labels from containers
    ↓
Routes to correct Docker container
    ↓
Container responds on internal port
Enter fullscreen mode Exit fullscreen mode

5.2 Traefik Labels Explained

In your docker-compose.yml:

labels:
  # Enable Traefik for this container
  - "traefik.enable=true"

  # Define routing rule (matches incoming domain)
  - "traefik.http.routers.backend-${PR_NUMBER}.rule=Host(`pr-${PR_NUMBER}-api.yourdomain.com`)"

  # Tell Traefik which port the container listens on
  - "traefik.http.services.backend-${PR_NUMBER}.loadbalancer.server.port=8000"

  # Optional: Enable HTTPS
  - "traefik.http.routers.backend-${PR_NUMBER}.tls=true"
  - "traefik.http.routers.backend-${PR_NUMBER}.tls.certresolver=letsencrypt"
Enter fullscreen mode Exit fullscreen mode

5.3 SSL Certificates

Traefik automatically requests Let's Encrypt certificates when:

  • Container has tls=true label
  • Domain resolves to server IP
  • Port 80/443 are accessible

No manual certificate management needed!

5.4 Verify Traefik Routing

# SSH into server
ssh ubuntu@

# Check Traefik dashboard (if enabled)
docker logs coolify-proxy

# Test routing
curl -I https://pr-123.yourdomain.com

# Should return 200 OK with SSL certificate
Enter fullscreen mode Exit fullscreen mode

Step 6: Database Migrations & Seeders

6.1 Alembic Migrations

Your backend/alembic/env.py should use environment variable:

# alembic/env.py (excerpt)
import os
from sqlalchemy import engine_from_config, pool

config.set_main_option(
    'sqlalchemy.url',
    os.getenv('DATABASE_URL', 'postgresql://user:pass@localhost/appdb')
)
Enter fullscreen mode Exit fullscreen mode

6.2 Running Migrations in Workflow

The GitHub Action runs migrations automatically:

- name: Run Database Migrations
  run: |
    ssh ubuntu@$HOST "docker exec preview-pr-${PR_NUMBER}-backend-1 \
      alembic upgrade head"
Enter fullscreen mode Exit fullscreen mode

What happens:

  1. GitHub Action SSHs into server
  2. Executes Alembic inside backend container
  3. Migrations run against isolated preview database
  4. If migration fails, deployment fails (good!)

6.3 Database Seeders

Create backend/db/seeders/seed_data.py:

# db/seeders/seed_data.py
import os
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select

# Your models
from app.models import User, Product, Category

async def seed_database():
    """Seed preview database with test data"""

    database_url = os.getenv('DATABASE_URL')

    # Convert to async URL if needed
    if database_url.startswith('postgresql://'):
        database_url = database_url.replace('postgresql://', 'postgresql+asyncpg://')

    engine = create_async_engine(database_url)
    async_session = sessionmaker(
        engine, class_=AsyncSession, expire_on_commit=False
    )

    async with async_session() as session:
        # Check if already seeded
        result = await session.execute(select(User).limit(1))
        if result.scalar_one_or_none():
            print("Database already seeded, skipping...")
            return

        # Seed users
        users = [
            User(email="admin@example.com", role="admin"),
            User(email="user@example.com", role="user"),
        ]
        session.add_all(users)

        # Seed categories
        categories = [
            Category(name="Electronics"),
            Category(name="Books"),
        ]
        session.add_all(categories)

        # Seed products
        products = [
            Product(name="Laptop", price=999.99, category=categories[0]),
            Product(name="Python Guide", price=29.99, category=categories[1]),
        ]
        session.add_all(products)

        await session.commit()
        print("✅ Database seeded successfully")

if __name__ == "__main__":
    asyncio.run(seed_database())
Enter fullscreen mode Exit fullscreen mode

6.4 Seeder Execution

GitHub Action runs seeder after migrations:

- name: Seed Database
  run: |
    ssh ubuntu@$HOST "docker exec preview-pr-${PR_NUMBER}-backend-1 \
      python db/seeders/seed_data.py"
Enter fullscreen mode Exit fullscreen mode

Flow:

PR opened
  ↓
Containers start
  ↓
Alembic runs: CREATE TABLE users, products...
  ↓
Seeder runs: INSERT test data
  ↓
Preview ready with data
Enter fullscreen mode Exit fullscreen mode

Step 7: Auto-Cleanup

7.1 How Cleanup Works

When PR is merged or closed:

PR closed/merged
    ↓
GitHub webhook triggers cleanup workflow
    ↓
SSH into server
    ↓
docker-compose down -v
    (stops containers, removes volumes)
    ↓
rm -rf /opt/previews/pr-123
    (removes code directory)
    ↓
Traefik auto-updates routes
    ↓
Resources freed
Enter fullscreen mode Exit fullscreen mode

7.2 Verify Cleanup

# Check for orphaned containers
docker ps -a | grep preview-pr

# Check for orphaned volumes
docker volume ls | grep preview-pr

# Check disk usage
df -h

# Check preview directories
ls /opt/previews/
Enter fullscreen mode Exit fullscreen mode

7.3 Manual Cleanup (if needed)

# SSH into server
ssh ubuntu@

# Stop and remove specific preview
cd /opt/previews/pr-123
docker-compose -p preview-pr-123 down -v
cd ..
rm -rf pr-123

# Nuclear option: clean ALL previews
docker system prune -a --volumes -f
rm -rf /opt/previews/pr-*
Enter fullscreen mode Exit fullscreen mode

Cost Breakdown

Monthly Costs

Infrastructure:

AWS Lightsail $12/mo:    $12/month (2-3 concurrent previews)
AWS Lightsail $24/mo:    $24/month (4-5 concurrent previews)
AWS Lightsail $40/mo:    $40/month (6-8 concurrent previews)

Coolify (open-source):   $0/month
Domain:                  ~$12/year ($1/month)
──────────────────────────────────────
Total:                   $13-41/month
Enter fullscreen mode Exit fullscreen mode

ROI Calculation

Time Savings (Conservative):

Per PR:
  - Developer time saved: 2 hours (no env conflicts)
  - QA time saved: 3 hours (immediate testing)
  Total: 5 hours per PR

Monthly (12 PRs):
  - 12 PRs × 5 hours = 60 hours saved
  - Value: 60 hours × $70/hour = $4,200/month

ROI:
  - Investment: $13-41/month
  - Return: $4,200/month
  - ROI: 10,000%+
Enter fullscreen mode Exit fullscreen mode

vs. Managed Alternatives

Platform Cost/Month Previews Setup Control
Coolify (This Setup) $13-41 Unlimited 3 hours Full
Vercel $200+ Unlimited 30 min Limited
Railway $150+ Per service 1 hour Limited
Heroku Review Apps $100+ Limited 2 hours Limited
Render $100+ Per service 1 hour Limited

Conclusion

You now have:

  • Automatic preview environments for every PR
  • Isolated testing (no more "who broke dev?")
  • Database migrations + seeders running automatically
  • SSL certificates handled by Traefik
  • Auto-cleanup on PR close
  • Total cost: $13-41/month

Next Steps:

  1. Try this with a small project first
  2. Gradually migrate team workflows
  3. Monitor costs and resource usage
  4. Scale Lightsail instance as team grows

Questions or issues? Drop them in the comments below!

Top comments (2)

Collapse
 
thomas545 profile image
Thomas Adel

One of the most practical, end-to-end guides I’ve seen on ephemeral environments, clear, actionable, and actually realistic for small teams. Great work.

Collapse
 
amirraouf profile image
Amir Raouf

Thanks Thomas, if you tried, I'm waiting your feedback