Skip to content

Adding New Apps to ERP-Unlocked Monorepo

This guide provides comprehensive instructions for adding new applications to the ERP-Unlocked monorepo. We currently support four types of applications, each with specific setup requirements and deployment considerations.

  1. Supported App Types
  2. Python FastAPI Applications
  3. Python Celery Worker Applications
  4. Node.js Server Applications
  5. Astro.js Applications
  6. GitHub Actions Configuration
  7. Deployment Considerations
  8. Internal Networking Setup
TypeUse CaseExamplePortAccess
Python FastAPIREST APIs, microservicesPDF Processor API8000Internal/Public
Python Celery WorkerBackground tasks, job processingPDF Worker-Internal
Node.js ServerAPIs, real-time servicesWebSocket server3000+Internal/Public
Astro.jsStatic sites, SSR appsMarketing site4321Public
my_fastapi_service/
├── app/
│ ├── __init__.py
│ ├── main.py # FastAPI app entry point
│ ├── models/
│ ├── routes/
│ ├── services/
│ └── utils/
├── tests/
├── Dockerfile
├── pyproject.toml
├── uv.lock
└── README.md
  1. Create the service directory:

    Terminal window
    mkdir my_fastapi_service
    cd my_fastapi_service
  2. Initialize Python project with uv:

    Terminal window
    uv init
    uv add fastapi uvicorn pydantic
    uv add --dev pytest pytest-asyncio httpx
  3. Create the main FastAPI app:

    app/main.py
    from fastapi import FastAPI
    from fastapi.middleware.cors import CORSMiddleware
    import os
    app = FastAPI(
    title="My FastAPI Service",
    description="Description of your service",
    version="1.0.0"
    )
    # CORS middleware
    app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"], # Configure appropriately
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
    )
    @app.get("/health")
    async def health_check():
    return {"status": "healthy", "service": "my-fastapi-service"}
    @app.get("/")
    async def root():
    return {"message": "My FastAPI Service"}
  4. Create Dockerfile:

    # syntax=docker/dockerfile:1
    # ---- Builder Stage ----
    FROM python:3.10-slim AS builder
    # Install system dependencies
    RUN apt-get update && apt-get install -y \
    build-essential \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*
    # Install uv
    COPY --from=ghcr.io/astral-sh/uv:0.7.12 /uv /uvx /usr/local/bin/
    # Copy dependency files
    COPY uv.lock pyproject.toml ./
    # Install dependencies using uv
    RUN uv sync --frozen --no-install-project
    # ---- Final Stage ----
    FROM python:3.10-slim AS final
    # Install system dependencies
    RUN apt-get update && apt-get install -y \
    libpq-dev \
    curl \
    && rm -rf /var/lib/apt/lists/*
    # Copy the virtual environment from the builder stage
    COPY --from=builder /.venv /.venv
    # Ensure the virtual environment is used
    ENV PATH="/.venv/bin:$PATH"
    # Install pip and observability tools
    RUN /.venv/bin/python -m ensurepip --upgrade && \
    /.venv/bin/python -m pip install --upgrade pip && \
    /.venv/bin/opentelemetry-bootstrap --action=install
    # Set working directory
    WORKDIR /service
    # Copy application code
    COPY app/ app/
    # Create an __init__.py to make app a proper Python package
    RUN echo "# Python package" > /service/app/__init__.py
    # Set environment variables
    ENV PYTHONPATH=/service
    ENV PYTHONUNBUFFERED=1
    # Make app directory readable
    RUN chmod -R 755 /service/app
    # Health check
    HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD curl -f http://localhost:8000/health || exit 1
    # Expose port
    EXPOSE 8000
    # Start the application
    CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
  5. Add tests:

    tests/test_main.py
    import pytest
    from fastapi.testclient import TestClient
    from app.main import app
    client = TestClient(app)
    def test_health_check():
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json()["status"] == "healthy"
    def test_root():
    response = client.get("/")
    assert response.status_code == 200
    assert "message" in response.json()
my_worker_service/
├── app/
│ ├── __init__.py
│ ├── celery_app.py # Celery app configuration
│ ├── tasks/
│ │ ├── __init__.py
│ │ └── example_tasks.py
│ ├── models/
│ ├── services/
│ └── utils/
├── tests/
├── Dockerfile
├── pyproject.toml
├── uv.lock
└── README.md
  1. Create the service directory:

    Terminal window
    mkdir my_worker_service
    cd my_worker_service
  2. Initialize Python project with uv:

    Terminal window
    uv init
    uv add celery redis
    uv add --dev pytest pytest-asyncio
  3. Create the Celery app:

    app/celery_app.py
    from celery import Celery
    import os
    # Configure Celery
    celery_app = Celery(
    "my_worker_service",
    broker=os.getenv("REDIS_URL", "redis://redis:6379/0"),
    backend=os.getenv("REDIS_URL", "redis://redis:6379/0"),
    include=["app.tasks.example_tasks"]
    )
    # Configure Celery settings
    celery_app.conf.update(
    task_serializer="json",
    accept_content=["json"],
    result_serializer="json",
    timezone="UTC",
    enable_utc=True,
    task_track_started=True,
    task_time_limit=30 * 60, # 30 minutes
    task_soft_time_limit=25 * 60, # 25 minutes
    worker_prefetch_multiplier=1,
    worker_max_tasks_per_child=1000,
    )
    if __name__ == "__main__":
    celery_app.start()
  4. Create example tasks:

    app/tasks/example_tasks.py
    from app.celery_app import celery_app
    import time
    @celery_app.task
    def example_task(message: str):
    """Example background task"""
    time.sleep(2) # Simulate work
    return f"Processed: {message}"
    @celery_app.task
    def health_check_task():
    """Health check task for monitoring"""
    return "Worker is healthy"
  5. Create Dockerfile:

    # syntax=docker/dockerfile:1
    # ---- Builder Stage ----
    FROM python:3.10-slim AS builder
    # Install system dependencies
    RUN apt-get update && apt-get install -y \
    build-essential \
    libpq-dev \
    && rm -rf /var/lib/apt/lists/*
    # Install uv
    COPY --from=ghcr.io/astral-sh/uv:0.7.12 /uv /uvx /usr/local/bin/
    # Copy dependency files
    COPY uv.lock pyproject.toml ./
    # Install dependencies using uv
    RUN uv sync --frozen --no-install-project
    # ---- Final Stage ----
    FROM python:3.10-slim AS final
    # Install system dependencies
    RUN apt-get update && apt-get install -y \
    libpq-dev \
    curl \
    && rm -rf /var/lib/apt/lists/*
    # Copy the virtual environment from the builder stage
    COPY --from=builder /.venv /.venv
    # Ensure the virtual environment is used
    ENV PATH="/.venv/bin:$PATH"
    # Install pip and observability tools
    RUN /.venv/bin/python -m ensurepip --upgrade && \
    /.venv/bin/python -m pip install --upgrade pip && \
    /.venv/bin/opentelemetry-bootstrap --action=install
    # Set working directory
    WORKDIR /service
    # Copy application code
    COPY app/ app/
    # Create an __init__.py to make app a proper Python package
    RUN echo "# Python package" > /service/app/__init__.py
    # Set environment variables
    ENV PYTHONPATH=/service
    ENV PYTHONUNBUFFERED=1
    # Make app directory readable
    RUN chmod -R 755 /service/app
    # Health check using Celery inspect
    HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
    CMD celery --workdir=/service -A app.celery_app inspect ping -d celery@$HOSTNAME || exit 1
    # Start the Celery worker
    CMD ["celery", "--workdir=/service", "-A", "app.celery_app", "worker", "--loglevel=info"]
my_node_service/
├── src/
│ ├── index.ts # Main entry point
│ ├── app.ts # Express app setup
│ ├── routes/
│ ├── middleware/
│ ├── services/
│ └── utils/
├── tests/
├── Dockerfile
├── package.json
├── tsconfig.json
├── .env.example
└── README.md
  1. Create the service directory:

    Terminal window
    mkdir my_node_service
    cd my_node_service
  2. Initialize Node.js project:

    Terminal window
    npm init -y
    npm install express cors helmet morgan dotenv
    npm install -D typescript @types/node @types/express @types/cors ts-node nodemon jest @types/jest supertest @types/supertest
  3. Create TypeScript configuration:

    {
    "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "lib": ["ES2020"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true,
    "declarationMap": true,
    "sourceMap": true
    },
    "include": ["src/**/*"],
    "exclude": ["node_modules", "dist", "tests"]
    }
  4. Create the main application:

    src/app.ts
    import express from 'express';
    import cors from 'cors';
    import helmet from 'helmet';
    import morgan from 'morgan';
    const app = express();
    // Middleware
    app.use(helmet());
    app.use(cors());
    app.use(morgan('combined'));
    app.use(express.json());
    app.use(express.urlencoded({ extended: true }));
    // Health check endpoint
    app.get('/health', (req, res) => {
    res.json({ status: 'healthy', service: 'my-node-service' });
    });
    // Root endpoint
    app.get('/', (req, res) => {
    res.json({ message: 'My Node.js Service' });
    });
    export default app;
    src/index.ts
    import app from './app';
    import dotenv from 'dotenv';
    dotenv.config();
    const PORT = process.env.PORT || 3000;
    app.listen(PORT, () => {
    console.log(`Server running on port ${PORT}`);
    });
  5. Create Dockerfile:

    # syntax=docker/dockerfile:1
    # ---- Dependencies Stage ----
    FROM node:18-alpine AS dependencies
    # Install pnpm
    RUN npm install -g pnpm
    WORKDIR /app
    # Copy package files
    COPY package.json pnpm-lock.yaml* ./
    # Install dependencies
    RUN pnpm install --frozen-lockfile
    # ---- Build Stage ----
    FROM dependencies AS build
    # Copy source code
    COPY . .
    # Build the application
    RUN pnpm run build
    # ---- Production Stage ----
    FROM node:18-alpine AS production
    # Install pnpm
    RUN npm install -g pnpm
    # Create app directory
    WORKDIR /app
    # Copy package files
    COPY package.json pnpm-lock.yaml* ./
    # Install production dependencies only
    RUN pnpm install --frozen-lockfile --prod
    # Copy built application
    COPY --from=build /app/dist ./dist
    # Create non-root user
    RUN addgroup -g 1001 -S nodejs
    RUN adduser -S nodejs -u 1001
    RUN chown -R nodejs:nodejs /app
    USER nodejs
    # Health check
    HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
    # Expose port
    EXPOSE 3000
    # Start the application
    CMD ["node", "dist/index.js"]
  6. Update package.json scripts:

    {
    "scripts": {
    "start": "node dist/index.js",
    "dev": "nodemon src/index.ts",
    "build": "tsc",
    "test": "jest",
    "lint": "eslint src/**/*.ts",
    "type-check": "tsc --noEmit"
    }
    }
my_astro_app/
├── src/
│ ├── pages/
│ ├── components/
│ ├── layouts/
│ └── content/
├── public/
├── dist/
├── Dockerfile
├── astro.config.mjs
├── package.json
├── tsconfig.json
└── README.md
  1. Create the Astro application:

    Terminal window
    npm create astro@latest my_astro_app
    cd my_astro_app
  2. Configure Astro for production:

    astro.config.mjs
    import { defineConfig } from 'astro/config';
    export default defineConfig({
    output: 'static', // or 'server' for SSR
    build: {
    assets: 'assets',
    },
    server: {
    port: 4321,
    host: true,
    },
    });
  3. Create Dockerfile:

    # syntax=docker/dockerfile:1
    # ---- Dependencies Stage ----
    FROM node:18-alpine AS dependencies
    # Install pnpm
    RUN npm install -g pnpm
    WORKDIR /app
    # Copy package files
    COPY package.json pnpm-lock.yaml* ./
    # Install dependencies
    RUN pnpm install --frozen-lockfile
    # ---- Build Stage ----
    FROM dependencies AS build
    # Copy source code
    COPY . .
    # Build the application
    RUN pnpm run build
    # ---- Production Stage (Static) ----
    FROM nginx:alpine AS production-static
    # Copy built assets
    COPY --from=build /app/dist /usr/share/nginx/html
    # Copy nginx configuration
    COPY nginx.conf /etc/nginx/nginx.conf
    # Health check
    HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost:80 || exit 1
    EXPOSE 80
    CMD ["nginx", "-g", "daemon off;"]
    # ---- Production Stage (SSR) ----
    FROM node:18-alpine AS production-ssr
    # Install pnpm
    RUN npm install -g pnpm
    WORKDIR /app
    # Copy package files
    COPY package.json pnpm-lock.yaml* ./
    # Install production dependencies
    RUN pnpm install --frozen-lockfile --prod
    # Copy built application
    COPY --from=build /app/dist ./dist
    # Create non-root user
    RUN addgroup -g 1001 -S nodejs
    RUN adduser -S nodejs -u 1001
    RUN chown -R nodejs:nodejs /app
    USER nodejs
    # Health check
    HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost:4321 || exit 1
    EXPOSE 4321
    CMD ["node", "./dist/server/entry.mjs"]
  4. Create nginx configuration (for static builds):

    nginx.conf
    events {
    worker_connections 1024;
    }
    http {
    include /etc/nginx/mime.types;
    default_type application/octet-stream;
    server {
    listen 80;
    server_name localhost;
    root /usr/share/nginx/html;
    index index.html;
    location / {
    try_files $uri $uri/ /index.html;
    }
    location /health {
    return 200 "healthy\n";
    add_header Content-Type text/plain;
    }
    }
    }

For each new service, update .github/workflows/ci-cd-pipeline.yml:

  1. Add path filters:

    filters: |
    my-new-service:
    - 'my_new_service/**'
    # existing filters...
  2. Add build job:

    build-my-new-service:
    runs-on: ubuntu-latest
    needs: detect-changes
    if: needs.detect-changes.outputs.my-new-service == 'true' || needs.detect-changes.outputs.force-build == 'true'
    permissions:
    contents: read
    packages: write
    steps:
    - name: Checkout code
    uses: actions/checkout@v4
    - name: Set up Docker Buildx
    uses: docker/setup-buildx-action@v3
    - name: Login to GitHub Container Registry
    uses: docker/login-action@v3
    with:
    registry: ${{ env.REGISTRY }}
    username: ${{ github.actor }}
    password: ${{ secrets.GITHUB_TOKEN }}
    - name: Extract metadata
    id: meta
    uses: docker/metadata-action@v5
    with:
    images: ${{ env.IMAGE_PREFIX }}/my-new-service
    tags: |
    type=ref,event=branch
    type=ref,event=pr
    type=sha,prefix={{branch}}-
    type=raw,value=latest,enable={{is_default_branch}}
    - name: Build and push
    uses: docker/build-push-action@v5
    with:
    context: ./my_new_service
    file: ./my_new_service/Dockerfile
    push: true
    tags: ${{ steps.meta.outputs.tags }}
    labels: ${{ steps.meta.outputs.labels }}
    cache-from: type=gha
    cache-to: type=gha,mode=max
  3. Update trigger-deployment job:

    trigger-deployment:
    runs-on: ubuntu-latest
    needs: [detect-changes, build-web, build-pdf-processor, build-my-new-service]
    # ... rest of the job

Add validation for new service in .github/workflows/branch-protection.yml:

validate-my-new-service:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
# Add appropriate validation steps based on service type
# For Python services:
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Install dependencies
run: |
cd my_new_service
uv sync --frozen
- name: Run tests
run: |
cd my_new_service
uv run pytest tests/
# For Node.js services:
- name: Setup pnpm
uses: pnpm/action-setup@v2
with:
version: 8
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
cache: 'pnpm'
- name: Install dependencies
run: |
cd my_new_service
pnpm install --frozen-lockfile
- name: Run tests
run: |
cd my_new_service
pnpm run test
  1. Create new service in Coolify:

    Terminal window
    # Via Coolify UI or API
    curl -X POST "https://coolify.yourdomain.com/api/v1/applications" \
    -H "Authorization: Bearer $COOLIFY_API_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{
    "name": "erp-unlocked-my-new-service",
    "description": "My new service",
    "docker_image": "ghcr.io/your-org/erp-unlocked/my-new-service:latest",
    "ports": "3000:3000",
    "environment_variables": {
    "NODE_ENV": "production"
    }
    }'
  2. Configure health checks:

    • Set health check endpoint (e.g., /health)
    • Configure appropriate timeout and interval
    • Set up monitoring and alerting
  3. Set up environment variables:

    • Database connection strings
    • API keys and secrets
    • Service configuration

Update existing services to communicate with new service:

// Example: Calling new service from existing service
const response = await fetch('http://erp-unlocked-my-new-service:3000/api/endpoint', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
  1. Ensure services are on the same Docker network:

    # In Coolify, services automatically join the 'coolify' network
    networks:
    - coolify
  2. Update service discovery:

    Terminal window
    # Services can reach each other using container names
    http://erp-unlocked-my-new-service:3000
    http://erp-unlocked-pdf-api:8000
    http://redis:6379
  3. Configure load balancing (if needed):

    # For public services, configure Traefik labels
    labels:
    - 'traefik.enable=true'
    - 'traefik.http.routers.my-service.rule=Host(`api.yourdomain.com`)'
    - 'traefik.http.routers.my-service.tls=true'
    - 'traefik.http.routers.my-service.tls.certresolver=letsencrypt'
  • Health check endpoint implemented
  • Proper error handling and logging
  • Environment variables for configuration
  • Dockerfile follows multi-stage build pattern
  • Tests cover core functionality
  • CI/CD pipeline updated
  • Documentation in README.md
  • Security considerations addressed
  • uv for dependency management
  • Virtual environment in Docker
  • OpenTelemetry for observability
  • Pydantic for data validation
  • Async/await where appropriate
  • TypeScript for type safety
  • pnpm for package management
  • ESLint and Prettier configured
  • Security middleware (helmet, cors)
  • Non-root user in Docker
  • Static vs SSR decision made
  • Build optimization configured
  • SEO considerations addressed
  • Performance optimizations applied
  1. Service can’t communicate with others:

    • Check Docker network configuration
    • Verify service names and ports
    • Test connectivity from within containers
  2. Build failures:

    • Review Dockerfile syntax
    • Check dependency installations
    • Verify build context
  3. Deployment failures:

    • Check Coolify logs
    • Verify image availability in GHCR
    • Review environment variables
Terminal window
# Check service connectivity
docker exec -it erp-unlocked-web curl http://erp-unlocked-my-new-service:3000/health
# View service logs
docker logs erp-unlocked-my-new-service
# Inspect network
docker network inspect coolify
# Check running containers
docker ps

Following this guide ensures consistent, secure, and maintainable service additions to the ERP-Unlocked monorepo. Each service type has specific requirements, but they all follow the same deployment pipeline and internal networking patterns.

Remember to:

  1. Update CI/CD workflows
  2. Configure Coolify deployment
  3. Set up proper monitoring
  4. Update documentation
  5. Test service communication

For questions or additional service types, refer to the existing implementations and update this guide accordingly.