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:
- Separate Docker Compose file for pgAdmin (
docker-compose.pgadmin.yml) — can be managed independently from the core stack - Backend API endpoint on the Gateway — starts/stops the pgAdmin container via Docker Compose commands
- UI toggle on a new "Developer Tools" settings page — one-click start/stop with real-time container status
- Resource savings — pgAdmin uses ~300 MB of memory; when stopped, these resources are freed for application services
- 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 juststop): Usingdownremoves the container entirely, freeing all resources. Thepgadmin-datavolume is declaredexternal: truein the pgAdmin Compose file, sodowndoes 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/statusand 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
],
}
12. Add Navigation Link¶
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 runninggetPgAdminStatus— returns correct status when container is stoppedstartPgAdmin— returns success when container startsstartPgAdmin— returns idempotent success when container already runningstopPgAdmin— returns success when container stopsstopPgAdmin— returns idempotent success when container already stoppedstartPgAdmin— returns failure message on Docker errorstopPgAdmin— 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-datavolume retained in main compose volumes section -
deployment/staging/docker-compose.pgadmin.ymlcreated with correct service definition - pgAdmin compose file uses
external: truefor network and volume -
docker compose -f docker-compose.pgadmin.yml up -dstarts pgAdmin successfully -
docker compose -f docker-compose.pgadmin.yml downstops pgAdmin without affecting other services - Traefik automatically routes to pgAdmin when it starts (no Traefik restart needed)
- TLS certificate for
staging-connect-db.forma3d.beis 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/statusreturns correct status (running/stopped) -
POST /api/v1/devtools/pgadmin/startstarts the container -
POST /api/v1/devtools/pgadmin/stopstops and removes the container - Start/stop are idempotent (calling start when running returns success, same for stop)
- API endpoints restricted to
ADMINrole 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
ADMINusers 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: truein the pgAdmin Compose file — this ensures pgAdmin joins the existing network without creating duplicates - Preserve the
pgadmin-datavolume across stop/start cycles —docker compose downdoes NOT remove external volumes - Restrict the DevTools API to the
ADMINrole 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-datavolume 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 -dworkflow — 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-vflag 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, oreslint-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:
- Phase 1 (Compose separation): Move the pgAdmin service definition back into
docker-compose.yml— one copy-paste operation - Phase 2 (Backend API): Remove the
DevToolsModuleimport fromAppModule— pgAdmin can still be managed manually via SSH - Phase 3 (Frontend UI): Remove the route — pgAdmin management falls back to CLI commands on the server
- 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.