Skip to content

AI Prompt: Forma3D.Connect — On-Demand pgAdmin Container Management

Purpose: Move pgAdmin to an on-demand container that can be started/stopped from the UI settings page, reducing idle memory usage (~300 MB) and attack surface
Estimated Effort: 8–12 hours (infrastructure + backend API + frontend toggle)
Prerequisites: Staging Docker Compose deployment operational with Traefik reverse proxy and TLS; pgAdmin currently running as an always-on container
Output: pgAdmin container managed via a separate Docker Compose file, controllable through a UI toggle on a new "Developer Tools" settings page — starting and stopping the container on demand without disrupting Traefik, TLS, or other services
Status:DONE


🎯 Mission

Extract the pgAdmin container from the main docker-compose.yml into a separate Docker Compose file that can be brought up or down independently. Add a backend API and a frontend UI toggle that allows administrators to start or stop pgAdmin on demand.

What this delivers:

  1. Separate Docker Compose file for pgAdmin (docker-compose.pgadmin.yml) — can be managed independently from the core stack
  2. Backend API endpoint on the Gateway — starts/stops the pgAdmin container via Docker Compose commands
  3. UI toggle on a new "Developer Tools" settings page — one-click start/stop with real-time container status
  4. Resource savings — pgAdmin uses ~300 MB of memory; when stopped, these resources are freed for application services
  5. Reduced attack surface — pgAdmin provides direct database access; running it only when needed minimizes exposure window

Why this matters:

  • Memory efficiency: The staging Droplet has limited RAM (4–8 GB). pgAdmin consumes ~300 MB even when idle — a significant chunk that could serve application workloads instead
  • Security: pgAdmin is a database administration tool with direct access to production data. Running it 24/7 creates a permanent attack surface. On-demand usage reduces the window of exposure to only when an administrator actively needs it
  • Operational discipline: Starting pgAdmin becomes a deliberate action ("I need to inspect the database") rather than a passive always-on service. This encourages using proper observability tools for day-to-day monitoring

What stays unchanged:

  • All core services (Gateway, Order Service, Print Service, Shipping Service, GridFlock, Slicer) remain in the main docker-compose.yml
  • Traefik routing and TLS certificate resolution are unaffected — pgAdmin keeps its own Traefik labels in the separate Compose file
  • The shared Docker network (forma3d-network) allows pgAdmin to connect to the same infrastructure when running
  • pgAdmin's persistent volume (pgadmin-data) is preserved across start/stop cycles — sessions, saved queries, and server configurations are retained

📐 Architecture

Current State

docker-compose.yml
├── traefik          (always on)
├── redis            (always on)
├── gateway          (always on)
├── order-service    (always on)
├── print-service    (always on)
├── shipping-service (always on)
├── gridflock-service(always on)
├── slicer           (always on)
├── web              (always on)
├── docs             (always on)
├── eventcatalog     (always on)
├── pgadmin          (always on — 300 MB idle)  ← MOVE THIS
├── uptime-kuma      (always on)
└── dozzle           (always on)

Target State

docker-compose.yml                     docker-compose.pgadmin.yml
├── traefik          (always on)       ├── pgadmin  (on demand)
├── redis            (always on)       │   ├── Same Traefik labels
├── gateway          (always on)       │   ├── Same network (external)
├── order-service    (always on)       │   ├── Same volume (pgadmin-data)
├── print-service    (always on)       │   └── Started/stopped via API
├── shipping-service (always on)
├── gridflock-service(always on)
├── slicer           (always on)
├── web              (always on)
├── docs             (always on)
├── eventcatalog     (always on)
├── uptime-kuma      (always on)
└── dozzle           (always on)

Control Flow

┌─────────────────────────────────────────────────────────────────┐
│                      Frontend (Web App)                          │
│                                                                  │
│  Settings → Developer Tools                                      │
│  ┌─────────────────────────────────────────────────┐            │
│  │  pgAdmin           [● Running]  [Stop]          │            │
│  │  Database admin tool at staging-connect-db...    │            │
│  │  Memory: ~300 MB | Status: healthy               │            │
│  └─────────────────────────────────────────────────┘            │
│                          │                                       │
│                    POST /api/v1/devtools/pgadmin/toggle          │
└──────────────────────────┼──────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Gateway (NestJS)                            │
│                                                                  │
│  DevToolsController                                              │
│    ├─ GET  /devtools/pgadmin/status → check container state      │
│    ├─ POST /devtools/pgadmin/start  → docker compose up -d       │
│    └─ POST /devtools/pgadmin/stop   → docker compose down        │
│                                                                  │
│  DevToolsService                                                 │
│    └─ Executes Docker Compose commands via child_process          │
│       docker compose -f docker-compose.pgadmin.yml               │
│       --project-name forma3d-connect up -d / down                │
└─────────────────────────────────────────────────────────────────┘
                           │
                           ▼
┌─────────────────────────────────────────────────────────────────┐
│                      Docker Engine                               │
│                                                                  │
│  forma3d-network (shared bridge)                                 │
│    ├── traefik ──► routes staging-connect-db.forma3d.be          │
│    └── pgadmin ──► dpage/pgadmin4 on port 5050                  │
│                    (only when started)                            │
└─────────────────────────────────────────────────────────────────┘

Docker Compose Separate File Strategy

The pgAdmin compose file uses external network and volume references to integrate with the main stack without coupling:

# docker-compose.pgadmin.yml
networks:
  forma3d-network:
    external: true    # ← Joins the existing network created by the main compose

volumes:
  pgadmin-data:
    external: true    # ← Uses the existing volume (preserves data across restarts)

This means: - docker compose -f docker-compose.pgadmin.yml up -d — starts pgAdmin, connects to existing network - docker compose -f docker-compose.pgadmin.yml down — stops and removes pgAdmin container only - Traefik automatically discovers pgAdmin via Docker labels (no Traefik restart needed) - TLS certificates are managed by Traefik's Let's Encrypt resolver (unchanged) - Stopping pgAdmin does NOT affect any other container or Traefik routing


📋 Services Affected

Service Changes Impact
Gateway Add DevTools controller + service + Docker socket mount New API endpoints for container management
Web Add "Developer Tools" settings page with pgAdmin toggle New UI page
Docker Compose (main) Remove pgAdmin service definition, keep volume declaration Infrastructure change
Docker Compose (pgAdmin) New separate file with pgAdmin service Infrastructure change

📁 Files to Create/Modify

New Files — Infrastructure

deployment/staging/docker-compose.pgadmin.yml    # Separate pgAdmin compose file

New Files — Backend

apps/gateway/src/devtools/devtools.module.ts           # Module definition
apps/gateway/src/devtools/devtools.controller.ts       # REST endpoints
apps/gateway/src/devtools/devtools.service.ts          # Docker Compose command execution
apps/gateway/src/devtools/dto/container-status.dto.ts  # Response DTOs
apps/gateway/src/devtools/__tests__/devtools.service.spec.ts  # Unit tests

New Files — Frontend

apps/web/src/pages/settings/developer-tools.tsx  # Developer Tools settings page

Modified Files — Infrastructure

deployment/staging/docker-compose.yml            # Remove pgAdmin service, keep pgadmin-data volume

Modified Files — Backend

apps/gateway/src/app.module.ts                   # Import DevToolsModule
apps/gateway/src/routing/route-config.ts         # Add devtools route (if gateway proxies it)

Modified Files — Frontend

apps/web/src/router.tsx                          # Add /settings/developer-tools route
apps/web/src/pages/settings/index.tsx            # Add link to Developer Tools page

🔧 Implementation Phases

Phase 1: Extract pgAdmin to Separate Docker Compose File (2 hours)

Priority: P0 | Impact: Foundation | Dependencies: None

1. Create separate pgAdmin Docker Compose file

Create deployment/staging/docker-compose.pgadmin.yml:

# ============================================================================
# Forma3D.Connect - On-Demand pgAdmin Container
# ============================================================================
# This file is managed separately from the main docker-compose.yml so that
# pgAdmin can be started/stopped independently without affecting core services.
#
# Usage:
#   Start:  docker compose -f docker-compose.pgadmin.yml up -d
#   Stop:   docker compose -f docker-compose.pgadmin.yml down
#   Status: docker compose -f docker-compose.pgadmin.yml ps
# ============================================================================

services:
  pgadmin:
    image: dpage/pgadmin4:latest
    container_name: forma3d-pgadmin
    restart: unless-stopped
    environment:
      - PGADMIN_DEFAULT_EMAIL=${PGADMIN_DEFAULT_EMAIL:-admin@forma3d.be}
      - PGADMIN_DEFAULT_PASSWORD=${PGADMIN_DEFAULT_PASSWORD}
      - PGADMIN_LISTEN_PORT=5050
    volumes:
      - pgadmin-data:/var/lib/pgadmin
    networks:
      - forma3d-network
    labels:
      - 'traefik.enable=true'
      - 'traefik.http.routers.pgadmin.rule=Host(`staging-connect-db.forma3d.be`)'
      - 'traefik.http.routers.pgadmin.entrypoints=websecure'
      - 'traefik.http.routers.pgadmin.tls=true'
      - 'traefik.http.routers.pgadmin.tls.certresolver=letsencrypt'
      - 'traefik.http.services.pgadmin.loadbalancer.server.port=5050'
    healthcheck:
      test: ['CMD', 'wget', '--no-verbose', '--tries=1', '--spider', 'http://localhost:5050/misc/ping']
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 30s

networks:
  forma3d-network:
    external: true

volumes:
  pgadmin-data:
    external: true

Key points: - networks.forma3d-network.external: true — joins the network created by the main Compose file - volumes.pgadmin-data.external: true — reuses the existing volume to preserve pgAdmin data - Traefik labels are identical to the current setup — Traefik discovers the container automatically when it starts - The .env file in the same directory is shared, so PGADMIN_DEFAULT_PASSWORD still works

2. Remove pgAdmin from main Docker Compose

Remove the entire pgadmin service block from deployment/staging/docker-compose.yml. Keep the pgadmin-data volume declaration in the volumes: section at the bottom — it's still used by the separate Compose file (but as external: true from the pgAdmin file's perspective, it needs to exist).

Important: The pgadmin-data volume in the main docker-compose.yml volumes section must remain so Docker creates it. If the volume already exists from a previous deployment, it will be reused automatically.

3. Verify Traefik integration

After extracting pgAdmin:

# Bring down pgAdmin if currently running
docker compose -f docker-compose.pgadmin.yml down

# Verify main stack is unaffected
docker compose ps

# Bring up pgAdmin separately
docker compose -f docker-compose.pgadmin.yml up -d

# Verify pgAdmin is accessible at staging-connect-db.forma3d.be
curl -s https://staging-connect-db.forma3d.be/misc/ping

# Verify TLS certificate is valid
curl -vI https://staging-connect-db.forma3d.be 2>&1 | grep 'SSL certificate'

# Bring it back down
docker compose -f docker-compose.pgadmin.yml down

# Verify Traefik is still serving other routes
curl -s https://staging-connect-api.forma3d.be/health/live
curl -s https://staging-connect.forma3d.be/

Phase 2: Backend API for Container Management (3–4 hours)

Priority: P0 | Impact: Core functionality | Dependencies: Phase 1

4. Create DevTools DTOs

Create apps/gateway/src/devtools/dto/container-status.dto.ts:

export interface ContainerStatusDto {
  name: string;
  displayName: string;
  description: string;
  isRunning: boolean;
  health: 'healthy' | 'unhealthy' | 'starting' | 'none' | 'unknown';
  url: string | null;
  memoryUsage: string | null;
  uptime: string | null;
  lastStartedAt: string | null;
}

export interface ContainerActionResultDto {
  success: boolean;
  action: 'start' | 'stop';
  containerName: string;
  message: string;
}

5. Create DevTools Service

Create apps/gateway/src/devtools/devtools.service.ts:

import { Injectable, Logger, ForbiddenException } from '@nestjs/common';
import { exec } from 'child_process';
import { promisify } from 'util';
import type { ContainerStatusDto, ContainerActionResultDto } from './dto/container-status.dto';

const execAsync = promisify(exec);

@Injectable()
export class DevToolsService {
  private readonly logger = new Logger(DevToolsService.name);

  private readonly PGADMIN_COMPOSE_FILE = 'docker-compose.pgadmin.yml';
  private readonly PGADMIN_CONTAINER_NAME = 'forma3d-pgadmin';
  private readonly COMPOSE_PROJECT_DIR = '/opt/forma3d/deployment/staging';

  /**
   * Get the current status of the pgAdmin container.
   */
  async getPgAdminStatus(): Promise<ContainerStatusDto> {
    const isRunning = await this.isContainerRunning(this.PGADMIN_CONTAINER_NAME);
    let health: ContainerStatusDto['health'] = 'none';
    let memoryUsage: string | null = null;
    let uptime: string | null = null;

    if (isRunning) {
      health = await this.getContainerHealth(this.PGADMIN_CONTAINER_NAME);
      memoryUsage = await this.getContainerMemoryUsage(this.PGADMIN_CONTAINER_NAME);
      uptime = await this.getContainerUptime(this.PGADMIN_CONTAINER_NAME);
    }

    return {
      name: 'pgadmin',
      displayName: 'pgAdmin',
      description: 'PostgreSQL database administration tool',
      isRunning,
      health,
      url: isRunning ? 'https://staging-connect-db.forma3d.be' : null,
      memoryUsage,
      uptime,
      lastStartedAt: null,
    };
  }

  /**
   * Start the pgAdmin container.
   */
  async startPgAdmin(): Promise<ContainerActionResultDto> {
    const isRunning = await this.isContainerRunning(this.PGADMIN_CONTAINER_NAME);

    if (isRunning) {
      return {
        success: true,
        action: 'start',
        containerName: this.PGADMIN_CONTAINER_NAME,
        message: 'pgAdmin is already running',
      };
    }

    try {
      this.logger.log('Starting pgAdmin container...');

      await execAsync(
        `docker compose -f ${this.PGADMIN_COMPOSE_FILE} up -d`,
        { cwd: this.COMPOSE_PROJECT_DIR, timeout: 60_000 }
      );

      this.logger.log('pgAdmin container started successfully');

      return {
        success: true,
        action: 'start',
        containerName: this.PGADMIN_CONTAINER_NAME,
        message: 'pgAdmin started successfully. It may take 15–30 seconds to become healthy.',
      };
    } catch (error) {
      this.logger.error(`Failed to start pgAdmin: ${error.message}`);
      return {
        success: false,
        action: 'start',
        containerName: this.PGADMIN_CONTAINER_NAME,
        message: `Failed to start pgAdmin: ${error.message}`,
      };
    }
  }

  /**
   * Stop and remove the pgAdmin container.
   * The pgadmin-data volume is preserved (not removed).
   */
  async stopPgAdmin(): Promise<ContainerActionResultDto> {
    const isRunning = await this.isContainerRunning(this.PGADMIN_CONTAINER_NAME);

    if (!isRunning) {
      return {
        success: true,
        action: 'stop',
        containerName: this.PGADMIN_CONTAINER_NAME,
        message: 'pgAdmin is already stopped',
      };
    }

    try {
      this.logger.log('Stopping pgAdmin container...');

      // `docker compose down` stops and removes the container but preserves external volumes
      await execAsync(
        `docker compose -f ${this.PGADMIN_COMPOSE_FILE} down`,
        { cwd: this.COMPOSE_PROJECT_DIR, timeout: 30_000 }
      );

      this.logger.log('pgAdmin container stopped and removed');

      return {
        success: true,
        action: 'stop',
        containerName: this.PGADMIN_CONTAINER_NAME,
        message: 'pgAdmin stopped successfully. Data is preserved for next start.',
      };
    } catch (error) {
      this.logger.error(`Failed to stop pgAdmin: ${error.message}`);
      return {
        success: false,
        action: 'stop',
        containerName: this.PGADMIN_CONTAINER_NAME,
        message: `Failed to stop pgAdmin: ${error.message}`,
      };
    }
  }

  private async isContainerRunning(containerName: string): Promise<boolean> {
    try {
      const { stdout } = await execAsync(
        `docker inspect --format='{{.State.Running}}' ${containerName}`,
        { timeout: 5000 }
      );
      return stdout.trim() === 'true';
    } catch {
      return false;
    }
  }

  private async getContainerHealth(containerName: string): Promise<ContainerStatusDto['health']> {
    try {
      const { stdout } = await execAsync(
        `docker inspect --format='{{.State.Health.Status}}' ${containerName}`,
        { timeout: 5000 }
      );
      const status = stdout.trim();
      if (status === 'healthy' || status === 'unhealthy' || status === 'starting') {
        return status;
      }
      return 'unknown';
    } catch {
      return 'unknown';
    }
  }

  private async getContainerMemoryUsage(containerName: string): Promise<string | null> {
    try {
      const { stdout } = await execAsync(
        `docker stats --no-stream --format='{{.MemUsage}}' ${containerName}`,
        { timeout: 10_000 }
      );
      return stdout.trim() || null;
    } catch {
      return null;
    }
  }

  private async getContainerUptime(containerName: string): Promise<string | null> {
    try {
      const { stdout } = await execAsync(
        `docker inspect --format='{{.State.StartedAt}}' ${containerName}`,
        { timeout: 5000 }
      );
      return stdout.trim() || null;
    } catch {
      return null;
    }
  }
}

Key design decisions:

  • docker compose down (not just stop): Using down removes the container entirely, freeing all resources. The pgadmin-data volume is declared external: true in the pgAdmin Compose file, so down does NOT remove it. All pgAdmin configuration, saved queries, and server connections persist.
  • Timeout protection: All Docker commands have timeouts to prevent hanging API requests.
  • Idempotent operations: Starting an already-running container or stopping an already-stopped container returns success without errors.

6. Create DevTools Controller

Create apps/gateway/src/devtools/devtools.controller.ts:

import { Controller, Get, Post, UseGuards, HttpCode, HttpStatus } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';
import { DevToolsService } from './devtools.service';
import { SessionGuard } from '../auth/guards/session.guard';
import { RequireRole } from '../auth/decorators/require-role.decorator';
import type { ContainerStatusDto, ContainerActionResultDto } from './dto/container-status.dto';

@ApiTags('DevTools')
@Controller('api/v1/devtools')
@UseGuards(SessionGuard)
@RequireRole('ADMIN')
export class DevToolsController {
  constructor(
    private readonly devToolsService: DevToolsService,
    private readonly tenantContext: TenantContextService,
  ) {}

  /**
   * Guard: pgAdmin management is only available to admins on the default tenant.
   * This is a platform-level tool, not a per-tenant feature.
   */
  private assertDefaultTenant(): void {
    const tenantId = this.tenantContext.getCurrentTenantId();
    const defaultTenantId = this.tenantContext.getDefaultTenantId();
    if (tenantId !== defaultTenantId) {
      throw new ForbiddenException('DevTools are only available on the default tenant');
    }
  }

  @Get('pgadmin/status')
  @ApiOperation({ summary: 'Get pgAdmin container status' })
  @ApiResponse({ status: 200, description: 'Current container status' })
  async getPgAdminStatus(): Promise<ContainerStatusDto> {
    this.assertDefaultTenant();
    return this.devToolsService.getPgAdminStatus();
  }

  @Post('pgadmin/start')
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: 'Start the pgAdmin container' })
  @ApiResponse({ status: 200, description: 'Container start result' })
  async startPgAdmin(): Promise<ContainerActionResultDto> {
    this.assertDefaultTenant();
    return this.devToolsService.startPgAdmin();
  }

  @Post('pgadmin/stop')
  @HttpCode(HttpStatus.OK)
  @ApiOperation({ summary: 'Stop and remove the pgAdmin container' })
  @ApiResponse({ status: 200, description: 'Container stop result' })
  async stopPgAdmin(): Promise<ContainerActionResultDto> {
    this.assertDefaultTenant();
    return this.devToolsService.stopPgAdmin();
  }
}

Important: The controller is restricted to the ADMIN role on the default tenant only. Only administrators on the default tenant should be able to start/stop infrastructure containers, since pgAdmin is a platform-level tool — not a per-tenant feature. The backend must verify both the role and the tenant context before executing Docker commands.

7. Create DevTools Module

Create apps/gateway/src/devtools/devtools.module.ts:

import { Module } from '@nestjs/common';
import { DevToolsController } from './devtools.controller';
import { DevToolsService } from './devtools.service';

@Module({
  controllers: [DevToolsController],
  providers: [DevToolsService],
})
export class DevToolsModule {}

8. Register DevTools Module

Update apps/gateway/src/app.module.ts:

import { DevToolsModule } from './devtools/devtools.module';

@Module({
  imports: [
    // ... existing modules
    DevToolsModule,
  ],
})
export class AppModule {}

9. Mount Docker Socket in Gateway Container

Update the Gateway service in deployment/staging/docker-compose.yml to mount the Docker socket:

gateway:
  # ... existing configuration ...
  volumes:
    - ./certs/do-postgresql-ca-certificate-2.crt:/app/certs/ca-certificate.crt:ro
    - /var/run/docker.sock:/var/run/docker.sock:ro    # NEW: for devtools container management
    - ./docker-compose.pgadmin.yml:/opt/forma3d/deployment/staging/docker-compose.pgadmin.yml:ro  # NEW
    - ./.env:/opt/forma3d/deployment/staging/.env:ro  # NEW: env vars for pgAdmin compose

Security note: Mounting the Docker socket gives the Gateway container full control over the Docker daemon. This is acceptable in a staging/self-hosted environment where the Gateway is already trusted. The API endpoint is restricted to the ADMIN role on the default tenant only. For production, consider using a more restricted approach like a Docker socket proxy (e.g., tecnativa/docker-socket-proxy).

Alternative approach: Instead of mounting the Docker socket inside the Gateway container, you could run the Docker commands on the host via SSH or a sidecar agent. However, this adds significant complexity. The Docker socket approach is simpler and appropriate for a self-hosted staging environment.


Phase 3: Frontend UI Toggle (3–4 hours)

Priority: P1 | Impact: User-facing | Dependencies: Phase 2

10. Create Developer Tools Settings Page

Create apps/web/src/pages/settings/developer-tools.tsx:

The page should include:

  • Page header: "Developer Tools" with breadcrumb back to Settings
  • pgAdmin card with:
  • Status indicator (green dot = running, gray dot = stopped)
  • Container health badge (healthy / unhealthy / starting / stopped)
  • Memory usage display (when running)
  • Uptime display (when running)
  • Direct link to pgAdmin URL (when running): staging-connect-db.forma3d.be
  • Start/Stop button with loading state and confirmation dialog for stopping
  • Description explaining the security and resource implications

UI Layout:

┌─────────────────────────────────────────────────────────────────┐
│  Settings / Developer Tools                                      │
│  Infrastructure tools for development and debugging              │
│                                                                  │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │  ┌──┐  pgAdmin — Database Administration                 │  │
│  │  │🟢│  PostgreSQL database administration tool            │  │
│  │  └──┘                                                     │  │
│  │                                                           │  │
│  │  Status: Running (healthy)                                │  │
│  │  Memory: 285.4 MiB / 512 MiB                             │  │
│  │  Uptime: 2 hours, 14 minutes                              │  │
│  │  URL: staging-connect-db.forma3d.be ↗                     │  │
│  │                                                           │  │
│  │  ┌──────────────────────────────────────────────────────┐ │  │
│  │  │ ⚠ Security note: pgAdmin provides direct access to  │ │  │
│  │  │ the database. Only run when actively needed.         │ │  │
│  │  └──────────────────────────────────────────────────────┘ │  │
│  │                                                           │  │
│  │                                          [ Stop pgAdmin ] │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                  │
│  ┌───────────────────────────────────────────────────────────┐  │
│  │  This page will accommodate additional on-demand          │  │
│  │  containers in the future (e.g., Grafana, ClickHouse).    │  │
│  └───────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘

Component behavior:

  • On page load: fetch GET /api/v1/devtools/pgadmin/status and display current state
  • Poll status every 5 seconds while the page is active (to update memory/health in near-real-time)
  • Start button: POST /api/v1/devtools/pgadmin/start, show spinner, then poll for status until healthy
  • Stop button: show confirmation dialog ("Stop pgAdmin? It will be removed but data is preserved."), then POST /api/v1/devtools/pgadmin/stop, show spinner, poll until stopped
  • When pgAdmin is starting (health = "starting"), show a progress indicator with message "pgAdmin is starting up... (15–30 seconds)"

11. Add Route to Settings

Update apps/web/src/router.tsx to add the Developer Tools route under settings:

{
  path: 'settings',
  children: [
    { index: true, element: <SettingsIndex /> },
    { path: 'integrations', element: <IntegrationsPage /> },
    { path: 'developer-tools', element: <DeveloperToolsPage /> },  // NEW
  ],
}

Update apps/web/src/pages/settings/index.tsx to include a link to the Developer Tools page:

Add a card or link alongside existing settings entries:

Developer Tools
  Manage infrastructure containers like pgAdmin
  → /settings/developer-tools

Only show this link to users with the ADMIN role on the default tenant. Non-default tenant admins and regular users should not see this page or its navigation link.


Phase 4: Gateway Routing (1 hour)

Priority: P1 | Impact: Medium | Dependencies: Phase 2

13. Add DevTools route to Gateway route config

If the Gateway uses a route configuration to proxy requests to downstream services, add the devtools routes. Since DevTools is a Gateway-native controller (not a downstream service), this may just require ensuring the Gateway handles /api/v1/devtools/* routes itself rather than proxying them.

Check apps/gateway/src/routing/route-config.ts and ensure devtools routes are registered or excluded from proxying to downstream services.


Phase 5: Testing & Validation (1–2 hours)

Priority: P1 | Impact: Quality | Dependencies: Phase 2

14. Unit Tests for DevTools Service

Create apps/gateway/src/devtools/__tests__/devtools.service.spec.ts:

Test cases:

  • getPgAdminStatus — returns correct status when container is running
  • getPgAdminStatus — returns correct status when container is stopped
  • startPgAdmin — returns success when container starts
  • startPgAdmin — returns idempotent success when container already running
  • stopPgAdmin — returns success when container stops
  • stopPgAdmin — returns idempotent success when container already stopped
  • startPgAdmin — returns failure message on Docker error
  • stopPgAdmin — returns failure message on Docker error

Note: Mock child_process.exec in tests — do not actually execute Docker commands in unit tests.

15. Manual Integration Testing

# From the staging server:

# 1. Verify pgAdmin is removed from main compose
docker compose ps | grep pgadmin  # Should show nothing

# 2. Start pgAdmin via API
curl -X POST https://staging-connect-api.forma3d.be/api/v1/devtools/pgadmin/start \
  -H "Cookie: <session-cookie>" \
  -H "Content-Type: application/json"

# 3. Verify pgAdmin is running
curl https://staging-connect-api.forma3d.be/api/v1/devtools/pgadmin/status \
  -H "Cookie: <session-cookie>"

# 4. Verify pgAdmin is accessible via Traefik with valid TLS
curl -I https://staging-connect-db.forma3d.be/misc/ping

# 5. Verify other services are unaffected
curl https://staging-connect-api.forma3d.be/health/live
curl https://staging-connect.forma3d.be/

# 6. Stop pgAdmin via API
curl -X POST https://staging-connect-api.forma3d.be/api/v1/devtools/pgadmin/stop \
  -H "Cookie: <session-cookie>" \
  -H "Content-Type: application/json"

# 7. Verify pgAdmin is stopped
docker ps | grep pgadmin  # Should show nothing

# 8. Verify pgAdmin data volume still exists
docker volume ls | grep pgadmin-data  # Should show the volume

# 9. Start again and verify data persists (server connections, saved queries)
curl -X POST https://staging-connect-api.forma3d.be/api/v1/devtools/pgadmin/start \
  -H "Cookie: <session-cookie>"

📊 Resource Impact

Memory Savings

State pgAdmin Memory Freed for App
Always on (current) ~285 MB 0 MB
On demand (stopped) 0 MB ~285 MB
On demand (running) ~285 MB 0 MB

On a 4 GB Droplet, freeing 285 MB represents ~7% of total RAM — significant for a constrained environment.

Security Surface Reduction

Risk Always On On Demand
Exposure window 24/7 (168 hrs/week) ~2–5 hrs/week (typical usage)
Attack surface pgAdmin login page publicly accessible 24/7 Only accessible when explicitly started
Time to exploit Unlimited Limited to active usage windows

✅ Validation Checklist

Infrastructure

  • pgAdmin service removed from deployment/staging/docker-compose.yml
  • pgadmin-data volume retained in main compose volumes section
  • deployment/staging/docker-compose.pgadmin.yml created with correct service definition
  • pgAdmin compose file uses external: true for network and volume
  • docker compose -f docker-compose.pgadmin.yml up -d starts pgAdmin successfully
  • docker compose -f docker-compose.pgadmin.yml down stops pgAdmin without affecting other services
  • Traefik automatically routes to pgAdmin when it starts (no Traefik restart needed)
  • TLS certificate for staging-connect-db.forma3d.be is valid when pgAdmin is running
  • pgAdmin data persists across stop/start cycles (saved servers, queries)
  • Main stack (docker compose up -d) works without pgAdmin
  • Docker socket mounted in Gateway container (read-only)

Backend API

  • GET /api/v1/devtools/pgadmin/status returns correct status (running/stopped)
  • POST /api/v1/devtools/pgadmin/start starts the container
  • POST /api/v1/devtools/pgadmin/stop stops and removes the container
  • Start/stop are idempotent (calling start when running returns success, same for stop)
  • API endpoints restricted to ADMIN role on default tenant only
  • Non-admin users receive 403 Forbidden
  • Admin users on non-default tenants receive 403 Forbidden
  • Docker command timeouts prevent API hangs
  • Container memory usage and health status returned correctly

Frontend

  • "Developer Tools" page accessible at /settings/developer-tools
  • pgAdmin status displayed with running/stopped indicator
  • Start button starts the container and shows loading state
  • Stop button shows confirmation dialog before stopping
  • Status auto-refreshes (polling every 5 seconds)
  • Link to pgAdmin URL shown only when running
  • Memory usage and uptime displayed when running
  • Page and navigation link only visible to ADMIN users on the default tenant
  • Navigation link added to Settings index page

Verification Commands

# Build passes
pnpm nx build gateway
pnpm nx build web

# Tests pass
pnpm nx test gateway
pnpm nx test web

# Lint passes
pnpm nx lint gateway
pnpm nx lint web

# Docker compose validation
docker compose -f deployment/staging/docker-compose.yml config --quiet
docker compose -f deployment/staging/docker-compose.pgadmin.yml config --quiet

🚫 Constraints and Rules

MUST DO

  • Use a separate Docker Compose file for pgAdmin — do NOT use docker compose --profile (profiles still load services into the Compose project and can cause confusion)
  • Declare network and volume as external: true in the pgAdmin Compose file — this ensures pgAdmin joins the existing network without creating duplicates
  • Preserve the pgadmin-data volume across stop/start cycles — docker compose down does NOT remove external volumes
  • Restrict the DevTools API to the ADMIN role on the default tenant only — this is a platform-level tool, not a per-tenant feature. Admin users on other tenants must not have access
  • Mount the Docker socket as read-only (:ro) in the Gateway container
  • Use timeouts on all Docker commands to prevent API request hangs
  • Keep the same Traefik labels and routing as the current pgAdmin setup — the URL should remain staging-connect-db.forma3d.be
  • Show a confirmation dialog before stopping pgAdmin in the UI
  • Make start/stop operations idempotent — calling start when already running should not error

MUST NOT

  • Remove the pgadmin-data volume when stopping pgAdmin — data must persist
  • Expose the Docker socket to the frontend or any unauthenticated endpoint
  • Allow non-admin users or admin users on non-default tenants to start/stop containers
  • Break the main docker compose up -d workflow — the core stack must start without pgAdmin
  • Modify Traefik configuration — pgAdmin uses the existing Traefik setup via Docker labels
  • Use docker compose down -v (the -v flag removes volumes)
  • Hard-code passwords or secrets in the Compose file — use environment variables
  • Add pgAdmin as a dependency (depends_on) of any core service
  • Use any, ts-ignore, or eslint-disable

SHOULD DO (Nice to Have)

  • Design the DevTools page to be extensible — in the future, other containers like Grafana could be managed the same way
  • Show an estimated startup time ("15–30 seconds") when starting pgAdmin
  • Add a "last used" timestamp to help decide whether to leave pgAdmin running
  • Consider adding Docker socket proxy (tecnativa/docker-socket-proxy) for production hardening
  • Add an auto-stop feature: if pgAdmin has been running for more than X hours with no web traffic, stop it automatically (future enhancement)
  • Log all start/stop actions with the user who performed them for audit purposes

🔄 Rollback Plan

If issues arise at any phase:

  1. Phase 1 (Compose separation): Move the pgAdmin service definition back into docker-compose.yml — one copy-paste operation
  2. Phase 2 (Backend API): Remove the DevToolsModule import from AppModule — pgAdmin can still be managed manually via SSH
  3. Phase 3 (Frontend UI): Remove the route — pgAdmin management falls back to CLI commands on the server
  4. Docker socket concerns: Remove the Docker socket volume mount from Gateway — DevTools API returns errors but everything else works

At all times, pgAdmin can be managed manually via SSH:

# Start
cd /opt/forma3d/deployment/staging
docker compose -f docker-compose.pgadmin.yml up -d

# Stop
docker compose -f docker-compose.pgadmin.yml down

📚 Key References

Existing Codebase: - Current Docker Compose: deployment/staging/docker-compose.yml (pgAdmin service at line 449) - Traefik config: deployment/staging/traefik.yml - Settings pages: apps/web/src/pages/settings/ - Gateway routing: apps/gateway/src/routing/route-config.ts - Integrations page (UI pattern reference): apps/web/src/pages/settings/integrations.tsx

Technologies: - Docker Compose CLI: https://docs.docker.com/compose/reference/ - Docker Compose external networks: https://docs.docker.com/compose/how-tos/networking/#use-a-pre-existing-network - Docker Engine API (inspect): https://docs.docker.com/engine/api/ - pgAdmin Docker: https://www.pgadmin.org/docs/pgadmin4/latest/container_deployment.html - Traefik Docker provider: https://doc.traefik.io/traefik/providers/docker/

Future extensibility: - The same pattern (separate Compose file + DevTools API) can be applied to other optional containers: - Grafana (when implemented via the ClickHouse logging prompt) - MailHog / Mailpit (email testing) - Any future development/debugging tool


END OF PROMPT


This prompt extracts the pgAdmin container from the main Docker Compose file into a separate docker-compose.pgadmin.yml that can be started and stopped independently. A new DevTools API on the Gateway executes Docker Compose commands to manage the container lifecycle, and a "Developer Tools" settings page in the frontend provides a one-click toggle for administrators. When stopped, pgAdmin frees ~300 MB of memory and eliminates its attack surface. When started, Traefik automatically routes to it with valid TLS — no restart or reconfiguration needed. The pgadmin-data volume is preserved across cycles so server connections and saved queries persist.