The friction in our code review process was becoming a significant bottleneck. Frontend pull requests were reviewed based on code alone, with reviewers having no immediate visual feedback on UI changes. Backend PRs required a cumbersome local setup for anyone wanting to perform integration testing. This asynchronous, high-friction loop resulted in longer cycle times and, more critically, bugs being merged that a simple interactive test would have caught. We needed to fundamentally change the feedback loop.
Our initial concept was to build an internal platform that could spin up a fully isolated, ephemeral preview environment for every single pull request. This environment would be accessible via a unique URL. The goal was to shift the review process from “does the code look right?” to “does the application behave as expected?”. To close the loop, we also envisioned a native mobile client for developers to get instant notifications and track the status of these environments, allowing for quicker turnaround even when away from a workstation.
Technology Selection and Rationale
In a real-world project, technology choices are driven by team expertise, operational simplicity, and fitness for the specific problem, not just by what’s new and shiny.
Backend Service (Koa.js): The heart of this system is a service that listens for webhooks from our Git provider (e.g., GitHub, GitLab). This is a purely I/O-bound task: receive a JSON payload, perform some logic, and make API calls to an orchestrator. Node.js is a natural fit. We chose Koa over Express for its modern async/await-centric middleware composition, which keeps the codebase cleaner and more manageable, especially when dealing with complex asynchronous flows.
Container Orchestration (Docker Swarm): We needed to dynamically create and destroy containerized application environments. While Kubernetes is the industry standard, its operational complexity was a non-starter for this internal tool. Our team is small, and we needed a solution we could manage without a dedicated platform engineering team. Docker Swarm, being integrated directly into the Docker Engine, offered a significantly lower barrier to entry. Its declarative YAML-based service definitions and simple CLI were sufficient for our use case of managing a few dozen ephemeral services at any given time. The trade-off in feature richness for operational simplicity was one we were willing to make.
Mobile Client (Jetpack Compose): For the mobile interface, the primary requirement was rapid development and a reactive UI that could respond to real-time status updates from the backend. Jetpack Compose, Android’s modern declarative UI toolkit, was the obvious choice. It allows us to describe the UI as a function of state, which pairs perfectly with a WebSocket-driven architecture for real-time updates. This avoids the boilerplate and complexity of traditional Android XML layouts and
RecyclerView
adapters.
Core Architecture: The Webhook-to-Preview Flow
The entire system hinges on a clean, automated flow. A Mermaid diagram illustrates this process from a developer’s push to a live, running environment.
sequenceDiagram participant Dev as Developer participant Git as Git Provider participant Koa as Koa Webhook Service participant Swarm as Docker Swarm Manager participant Traefik as Ingress Proxy Dev->>+Git: git push (to feature branch) Git->>+Koa: POST /webhook (Pull Request Event) Koa->>Koa: 1. Validate Signature & Parse Payload Koa->>Swarm: 2. Create Service (docker service create) Swarm-->>Swarm: Pulls image, schedules container Note right of Swarm: Service is labeled with PR number Swarm->>Traefik: Registers new service Note left of Traefik: Traefik automatically detects the new service via Docker labels Koa-->>-Git: HTTP 202 Accepted Koa->>Git: 3. Post Comment to PR with Preview URL Dev->>Git: Receives notification
The Koa Webhook Service Implementation
The Koa service is the central nervous system. It must be robust, secure, and capable of interacting with the Docker Swarm manager.
First, the server setup. We use koa
, koa-router
, and koa-bodyparser
. For interacting with the Docker daemon, the dockerode
library is a solid choice.
// src/server.js
const Koa = require('koa');
const Router = require('koa-router');
const bodyParser = require('koa-bodyparser');
const crypto = require('crypto');
const Docker = require('dockerode');
const { deployPreview, cleanupPreview } = require('./swarm.service');
const app = new Koa();
const router = new Router();
// A critical piece of production-grade code: middleware to verify webhook signatures.
// Without this, anyone could send a POST request to our endpoint and trigger deployments.
const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;
const verifyGithubSignature = async (ctx, next) => {
if (!GITHUB_WEBHOOK_SECRET) {
console.warn('Webhook secret not configured. Skipping verification.');
return next();
}
const signature = ctx.get('X-Hub-Signature-256');
if (!signature) {
ctx.throw(400, 'Signature required');
}
const hmac = crypto.createHmac('sha256', GITHUB_WEBHOOK_SECRET);
hmac.update(ctx.request.rawBody, 'utf-8');
const expectedSignature = `sha256=${hmac.digest('hex')}`;
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
ctx.throw(401, 'Invalid signature');
}
await next();
};
router.post('/webhook', verifyGithubSignature, async (ctx) => {
const event = ctx.get('X-GitHub-Event');
const payload = ctx.request.body;
// We only care about pull request events.
if (event !== 'pull_request') {
ctx.status = 204; // No Content
return;
}
const { action, pull_request: pr } = payload;
console.log(`Received PR event: PR #${pr.number} - ${action}`);
try {
if (action === 'opened' || action === 'synchronize') {
// 'synchronize' is triggered on new commits to the PR branch.
await deployPreview({
prNumber: pr.number,
branch: pr.head.ref,
commitSha: pr.head.sha,
repoName: pr.head.repo.name,
});
// Acknowledge immediately. The deployment happens in the background.
ctx.body = { message: `Deployment triggered for PR #${pr.number}` };
ctx.status = 202; // Accepted
} else if (action === 'closed') {
await cleanupPreview(pr.number);
ctx.body = { message: `Cleanup triggered for PR #${pr.number}` };
ctx.status = 202;
} else {
ctx.status = 204; // Event ignored
}
} catch (error) {
console.error(`Failed to process webhook for PR #${pr.number}:`, error);
ctx.status = 500;
ctx.body = { error: 'Internal Server Error', details: error.message };
}
});
app
.use(bodyParser({
enableTypes: ['json'],
// We need the raw body for signature verification.
extendTypes: {
json: ['application/x-hub-webhook+json'],
},
jsonLimit: '5mb',
onError: (err, ctx) => {
ctx.throw(422, 'Body parse error');
}
}))
.use(router.routes())
.use(router.allowedMethods());
const port = process.env.PORT || 3000;
app.listen(port, () => {
console.log(`Webhook listener running on port ${port}`);
});
Interfacing with Docker Swarm Programmatically
This is where the magic happens. The swarm.service.js
module contains the logic to create and destroy Docker Swarm services. A common mistake here is hardcoding configurations. A better approach is to use environment variables and templating to make the service definitions flexible.
// src/swarm.service.js
const Docker = require('dockerode');
// Connect to the Docker daemon via its socket.
// In a production Swarm setup, the Koa service container would have the
// Docker socket mounted: -v /var/run/docker.sock:/var/run/docker.sock
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
const DOMAIN = process.env.PREVIEW_DOMAIN || 'preview.example.com';
const NETWORK_NAME = process.env.SWARM_NETWORK || 'traefik-public';
/**
* Deploys a new preview environment as a Docker Swarm service.
* @param {object} details - The PR details.
* @param {number} details.prNumber
* @param {string} details.commitSha
* @param {string} details.repoName
*/
async function deployPreview({ prNumber, commitSha, repoName }) {
// Assume a CI process has already built and pushed a Docker image
// tagged with the commit SHA.
const imageName = `my-registry/${repoName}:${commitSha.substring(0, 7)}`;
const serviceName = `pr-${prNumber}-${repoName}`;
const host = `pr-${prNumber}-${repoName}.${DOMAIN}`;
console.log(`Deploying service ${serviceName} for image ${imageName}`);
// We use labels as the source of truth for managing these services.
// This is more reliable than maintaining a separate database.
const serviceLabels = {
'preview.managed': 'true',
'preview.pr_number': String(prNumber),
'preview.repo': repoName,
// Traefik labels for automatic routing
'traefik.enable': 'true',
[`traefik.http.routers.${serviceName}.rule`]: `Host(\`${host}\`)`,
[`traefik.http.routers.${serviceName}.entrypoints`]: 'websecure',
[`traefik.http.services.${serviceName}.loadbalancer.server.port`]: '8080', // App's internal port
};
const serviceSpec = {
Name: serviceName,
Labels: serviceLabels,
TaskTemplate: {
ContainerSpec: {
Image: imageName,
Labels: { 'preview.managed': 'true' } // Also label the container
},
RestartPolicy: {
Condition: 'on-failure',
Delay: 5000000000, // 5 seconds
MaxAttempts: 3,
},
},
Networks: [{ Target: NETWORK_NAME }],
Mode: {
Replicated: {
Replicas: 1,
},
},
};
try {
// Check if the service already exists and update it, otherwise create it.
// This handles the 'synchronize' event (new pushes to the same PR).
const existingService = docker.getService(serviceName);
await existingService.inspect(); // Throws error if not found
console.log(`Service ${serviceName} already exists. Updating...`);
// To update, we need the current version number to avoid race conditions.
const serviceInfo = await existingService.inspect();
await existingService.update({ ...serviceSpec, version: serviceInfo.Version.Index });
} catch (error) {
if (error.statusCode === 404) {
console.log(`Service ${serviceName} not found. Creating...`);
await docker.createService(serviceSpec);
} else {
// Re-throw other errors
throw error;
}
}
console.log(`Service ${serviceName} deployed successfully. Accessible at https://${host}`);
}
/**
* Cleans up a preview environment by removing the associated service.
* @param {number} prNumber
*/
async function cleanupPreview(prNumber) {
console.log(`Cleaning up environments for PR #${prNumber}`);
// Find all services associated with this PR number using labels.
const services = await docker.listServices({
filters: { label: [`preview.pr_number=${prNumber}`] },
});
if (services.length === 0) {
console.log(`No services found for PR #${prNumber}. Nothing to clean up.`);
return;
}
for (const serviceInfo of services) {
try {
const service = docker.getService(serviceInfo.ID);
await service.remove();
console.log(`Successfully removed service ${serviceInfo.Spec.Name} (ID: ${serviceInfo.ID})`);
} catch (error) {
console.error(`Failed to remove service ${serviceInfo.Spec.Name}:`, error);
// Continue to try and remove other services
}
}
}
module.exports = { deployPreview, cleanupPreview };
A key pitfall to avoid here is race conditions. When updating a service, Docker Swarm requires the service’s current Version.Index
. Fetching it right before the update call (existingService.update({ ...serviceSpec, version: serviceInfo.Version.Index })
) is crucial for ensuring transactional updates.
The Jetpack Compose Mobile Interface
The mobile client’s purpose is to provide an at-a-glance overview of all active pull requests and their preview environment statuses. It connects to a WebSocket endpoint on our Koa server for real-time updates.
First, let’s augment the Koa server with WebSocket support using the ws
library.
// In server.js, after app.listen()
const WebSocket = require('ws');
const http = require('http');
// ... existing Koa app setup
const server = http.createServer(app.callback());
const wss = new WebSocket.Server({ server });
wss.on('connection', ws => {
console.log('Client connected to WebSocket');
ws.on('close', () => console.log('Client disconnected'));
});
// Function to broadcast messages to all connected clients
function broadcast(data) {
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(data));
}
});
}
// We need to call broadcast() inside deployPreview and cleanupPreview
// For example, in swarm.service.js:
// ... after deployment success
// broadcast({ type: 'DEPLOY_SUCCESS', prNumber, host });
// ... in cleanupPreview
// broadcast({ type: 'CLEANUP_SUCCESS', prNumber });
server.listen(port, () => {
console.log(`Server with WebSocket support running on port ${port}`);
});
// Make broadcast available to other modules
module.exports.broadcast = broadcast;
Now for the Android client. The core components are a ViewModel
to manage state and a Composable
function to render the UI. We use OkHttp
for the WebSocket connection and Kotlin’s StateFlow
to represent the UI state.
// PreviewViewModel.kt
package com.example.reviewapp.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import okhttp3.*
import okio.ByteString
// Data class to represent the state of a single PR
data class PullRequestState(
val id: Int,
val title: String,
val author: String,
val status: String, // e.g., "DEPLOYING", "READY", "CLEANING_UP"
val previewUrl: String?
)
class PreviewViewModel : ViewModel() {
private val _pullRequests = MutableStateFlow<List<PullRequestState>>(emptyList())
val pullRequests = _pullRequests.asStateFlow()
private val client = OkHttpClient()
private lateinit var webSocket: WebSocket
init {
connectWebSocket()
}
private fun connectWebSocket() {
val request = Request.Builder().url("ws://your-server-ip:3000").build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
// Connection opened, maybe request initial state
viewModelScope.launch {
// In a real app, you'd fetch the initial list via a REST endpoint.
// For simplicity, we start empty.
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
// This is where we update our state based on server messages
// A production app would use a proper JSON parser like Moshi or Gson
val parts = text.split(":") // Simplified parsing
val type = parts[0]
val prNumber = parts[1].toInt()
viewModelScope.launch {
val currentList = _pullRequests.value.toMutableList()
val existingIndex = currentList.indexOfFirst { it.id == prNumber }
when(type) {
"DEPLOY_SUCCESS" -> {
val url = parts[2]
if (existingIndex != -1) {
currentList[existingIndex] = currentList[existingIndex].copy(status = "READY", previewUrl = url)
} else {
// This assumes we get full PR data from the event.
// In reality, the event might just be a signal to fetch the full data.
currentList.add(PullRequestState(prNumber, "PR #$prNumber", "Unknown Author", "READY", url))
}
}
"CLEANUP_SUCCESS" -> {
if (existingIndex != -1) {
currentList.removeAt(existingIndex)
}
}
}
_pullRequests.value = currentList.sortedByDescending { it.id }
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
// Handle connection failure, implement retry logic
}
})
}
override fun onCleared() {
super.onCleared()
webSocket.close(1000, "ViewModel cleared")
client.dispatcher.executorService.shutdown()
}
}
The corresponding Jetpack Compose UI is then a straightforward representation of this state.
// PreviewScreen.kt
package com.example.reviewapp.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun PreviewScreen(viewModel: PreviewViewModel) {
val pullRequests by viewModel.pullRequests.collectAsState()
Scaffold(
topBar = { TopAppBar(title = { Text("Active Pull Requests") }) }
) { paddingValues ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(pullRequests, key = { it.id }) { pr ->
PullRequestCard(pr)
}
}
}
}
@Composable
fun PullRequestCard(pr: PullRequestState) {
Card(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column(Modifier.padding(16.dp)) {
Text("PR #${pr.id}: ${pr.title}", style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Text("by ${pr.author}", style = MaterialTheme.typography.bodySmall)
Spacer(Modifier.height(8.dp))
val statusColor = when (pr.status) {
"READY" -> MaterialTheme.colorScheme.primary
"DEPLOYING" -> MaterialTheme.colorScheme.secondary
else -> MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
}
Text("Status: ${pr.status}", color = statusColor, style = MaterialTheme.typography.labelMedium)
pr.previewUrl?.let { url ->
Text("URL: $url", style = MaterialTheme.typography.bodyMedium)
}
}
}
}
This pragmatic combination provides a powerful feedback loop. The backend handles the heavy lifting of orchestration, while the native mobile client offers a low-friction interface for developers to consume the results.
Limitations and Future Trajectory
This solution, while effective, is not without its boundaries. Docker Swarm’s simplicity comes at the cost of advanced features like sophisticated scheduling, auto-scaling based on custom metrics, or a rich ecosystem of plugins that Kubernetes offers. For a larger organization or more complex workloads, migrating the orchestration logic to a Kubernetes Operator would be a logical next step.
The current implementation also presents security considerations. The preview environments are publicly accessible on subdomains. A future iteration must integrate an authentication layer, possibly via OAuth2 proxy injection, to ensure only authorized team members can access them. Furthermore, resource management is reactive. We should implement a mechanism to automatically clean up environments that have been inactive for a certain period (e.g., 24 hours) to control costs and prevent resource exhaustion on the cluster. The mobile client is currently a read-only view; extending it to allow actions like approving a PR or triggering a rebuild would further reduce friction in the development cycle.