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)
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
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
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>
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
2.2 Access Coolify UI
# Navigate to:
http://:8000
# Or (if DNS is configured):
https://coolify.yourdomain.com
2.3 Initial Setup
-
Create admin account
- Set strong password
- Save credentials securely
-
Configure server
- Coolify auto-detects localhost
- Server should show as "Connected"
-
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
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
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>
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
});
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}`
});
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
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"
5.3 SSL Certificates
Traefik automatically requests Let's Encrypt certificates when:
- Container has
tls=truelabel - 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
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')
)
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"
What happens:
- GitHub Action SSHs into server
- Executes Alembic inside backend container
- Migrations run against isolated preview database
- 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())
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"
Flow:
PR opened
↓
Containers start
↓
Alembic runs: CREATE TABLE users, products...
↓
Seeder runs: INSERT test data
↓
Preview ready with data
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
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/
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-*
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
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%+
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:
- Try this with a small project first
- Gradually migrate team workflows
- Monitor costs and resource usage
- Scale Lightsail instance as team grows
Questions or issues? Drop them in the comments below!
Top comments (2)
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.
Thanks Thomas, if you tried, I'm waiting your feedback