Skip to content

Instantly share code, notes, and snippets.

@TheLustriVA
Last active September 25, 2025 07:48
Show Gist options
  • Save TheLustriVA/12e34c83de3c3749fa453d3088fe949d to your computer and use it in GitHub Desktop.
Save TheLustriVA/12e34c83de3c3749fa453d3088fe949d to your computer and use it in GitHub Desktop.
A quick one-page web app for converting Docker Compose files to Portainer and Yacht templates
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Compose Converter</title>
<link rel="icon" type="image/x-icon" href="/static/favicon.ico">
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://unpkg.com/[email protected]/dist/aos.css" rel="stylesheet">
<script src="https://unpkg.com/[email protected]/dist/aos.js"></script>
<script src="https://cdn.jsdelivr.net/npm/feather-icons/dist/feather.min.js"></script>
<script src="https://unpkg.com/feather-icons"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js-yaml.min.js"></script>
</head>
<body class="bg-gray-900 text-gray-100 min-h-screen">
<!-- Header -->
<header class="bg-gradient-to-r from-blue-800 to-purple-800 text-white shadow-lg">
<div class="container mx-auto px-4 py-6">
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<i data-feather="layers" class="w-8 h-8"></i>
<h1 class="text-2xl font-bold">Compose Converter</h1>
</div>
<p class="text-blue-200">Convert Docker Compose to Portainer & Yacht Templates</p>
</div>
</div>
</header>
<!-- Main Content -->
<main class="container mx-auto px-4 py-8 max-w-7xl">
<!-- Input Section -->
<section class="bg-gray-800 rounded-xl shadow-lg p-6 mb-8" data-aos="fade-up">
<h2 class="text-xl font-semibold text-gray-800 mb-4 flex items-center">
<i data-feather="upload-cloud" class="w-5 h-5 mr-2 text-blue-600"></i>
Input Docker Compose
</h2>
<!-- File Upload -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2">Upload docker-compose.yml file</label>
<div class="flex items-center justify-center w-full">
<label for="file-upload" class="flex flex-col items-center justify-center w-full h-32 border-2 border-gray-600 border-dashed rounded-lg cursor-pointer bg-gray-700 hover:bg-gray-600 transition-colors">
<div class="flex flex-col items-center justify-center pt-5 pb-6">
<i data-feather="upload" class="w-8 h-8 mb-3 text-gray-300"></i>
<p class="mb-2 text-sm text-gray-300">Click to upload or drag and drop</p>
<p class="text-xs text-gray-400">YAML files only</p>
</div>
<input id="file-upload" type="file" class="hidden" accept=".yml,.yaml" />
</label>
</div>
</div>
<!-- Or Divider -->
<div class="flex items-center my-6">
<div class="flex-grow border-t border-gray-600"></div>
<span class="flex-shrink mx-4 text-gray-400">or</span>
<div class="flex-grow border-t border-gray-600"></div>
</div>
<!-- Text Input -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-300 mb-2">Paste docker-compose.yml content</label>
<textarea
id="compose-input"
class="w-full h-64 px-4 py-2 border border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all font-mono text-sm bg-gray-700 text-gray-100"
placeholder="version: '3.8'
services:
web:
image: nginx:alpine
ports:
- '80:80'"
></textarea>
</div>
<!-- Action Buttons -->
<div class="flex flex-wrap gap-4">
<button
id="validate-btn"
class="px-6 py-2 bg-green-700 text-white rounded-lg hover:bg-green-600 transition-colors flex items-center"
>
<i data-feather="check-circle" class="w-4 h-4 mr-2"></i>
Validate YAML
</button>
<button
id="convert-btn"
class="px-6 py-2 bg-blue-700 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center disabled:bg-gray-600 disabled:cursor-not-allowed"
disabled
>
<i data-feather="refresh-cw" class="w-4 h-4 mr-2"></i>
Convert Templates
</button>
</div>
<!-- Validation Status -->
<div id="validation-status" class="mt-4 hidden"></div>
</section>
<!-- Output Section -->
<section class="grid md:grid-cols-2 gap-8" data-aos="fade-up" data-aos-delay="200">
<!-- Portainer Template -->
<div class="bg-gray-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-100 mb-4 flex items-center">
<i data-feather="anchor" class="w-5 h-5 mr-2 text-blue-600"></i>
Portainer Template
</h2>
<textarea
id="portainer-output"
class="w-full h-64 px-4 py-2 border border-gray-600 rounded-lg font-mono text-sm bg-gray-700 text-gray-100"
readonly
placeholder="Generated Portainer template will appear here..."
></textarea>
<div class="flex gap-2 mt-4">
<button
id="copy-portainer"
class="flex-1 px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors flex items-center justify-center"
>
<i data-feather="copy" class="w-4 h-4 mr-2"></i>
Copy
</button>
<button
id="download-portainer"
class="flex-1 px-4 py-2 bg-blue-700 text-white rounded-lg hover:bg-blue-600 transition-colors flex items-center justify-center"
>
<i data-feather="download" class="w-4 h-4 mr-2"></i>
Download
</button>
</div>
</div>
<!-- Yacht Template -->
<div class="bg-gray-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-100 mb-4 flex items-center">
<i data-feather="ship" class="w-5 h-5 mr-2 text-purple-600"></i>
Yacht Template
</h2>
<textarea
id="yacht-output"
class="w-full h-64 px-4 py-2 border border-gray-600 rounded-lg font-mono text-sm bg-gray-700 text-gray-100"
readonly
placeholder="Generated Yacht template will appear here..."
></textarea>
<div class="flex gap-2 mt-4">
<button
id="copy-yacht"
class="flex-1 px-4 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors flex items-center justify-center"
>
<i data-feather="copy" class="w-4 h-4 mr-2"></i>
Copy
</button>
<button
id="download-yacht"
class="flex-1 px-4 py-2 bg-purple-700 text-white rounded-lg hover:bg-purple-600 transition-colors flex items-center justify-center"
>
<i data-feather="download" class="w-4 h-4 mr-2"></i>
Download
</button>
</div>
</div>
</section>
</main>
<!-- Footer -->
<footer class="bg-gray-950 text-white mt-16 py-8">
<div class="container mx-auto px-4 text-center items-center">
<p class="text-gray-300">Made with <i data-feather="heart" class="w-4 h-4 inline text-red-500"></i> for the Docker Community</p>
<p class="text-gray-300">Code available as a <a href="https://gist.github.com/TheLustriVA/12e34c83de3c3749fa453d3088fe949d">Gist</a>
</p>
<p class="text-gray-300"><a href="https://gist.github.com/TheLustriVA/12e34c83de3c3749fa453d3088fe949d">Compose Converter</a> by <a href="https://github.com/TheLustriVa">Kieran Bicheno</a> is marked <a href="https://creativecommons.org/publicdomain/zero/1.0/">CC0 1.0</a></p>
<p class="text-gray-300">
<img src="https://mirrors.creativecommons.org/presskit/icons/cc.svg" alt="" style="max-width: 1em;max-height:1em;margin-left: .2em;">
<img src="https://mirrors.creativecommons.org/presskit/icons/zero.svg" alt="" style="max-width: 1em;max-height:1em;margin-left: .2em;">
</p>
</div>
</footer>
<script>
// Initialize Feather Icons
feather.replace();
// Initialize AOS
AOS.init({
duration: 800,
once: true
});
// State
let isValidYAML = false;
let composeData = null;
// DOM Elements
const fileUpload = document.getElementById('file-upload');
const composeInput = document.getElementById('compose-input');
const validateBtn = document.getElementById('validate-btn');
const convertBtn = document.getElementById('convert-btn');
const validationStatus = document.getElementById('validation-status');
const portainerOutput = document.getElementById('portainer-output');
const yachtOutput = document.getElementById('yacht-output');
const copyPortainer = document.getElementById('copy-portainer');
const downloadPortainer = document.getElementById('download-portainer');
const copyYacht = document.getElementById('copy-yacht');
const downloadYacht = document.getElementById('download-yacht');
// File Upload Handler
fileUpload.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file && (file.name.endsWith('.yml') || file.name.endsWith('.yaml'))) {
const reader = new FileReader();
reader.onload = (e) => {
composeInput.value = e.target.result;
validateYAML();
};
reader.readAsText(file);
} else {
showValidationStatus('Please select a valid YAML file', 'error');
}
});
// Validate YAML
function validateYAML() {
try {
const yaml = composeInput.value.trim();
if (!yaml) {
throw new Error('YAML content is empty');
}
composeData = jsyaml.load(yaml);
if (!composeData.services) {
throw new Error('No services found in docker-compose.yml');
}
isValidYAML = true;
convertBtn.disabled = false;
showValidationStatus('✅ Valid docker-compose.yml', 'success');
return true;
} catch (error) {
isValidYAML = false;
convertBtn.disabled = true;
showValidationStatus(`❌ ${error.message}`, 'error');
return false;
}
}
// Show Validation Status
function showValidationStatus(message, type) {
validationStatus.innerHTML = `<div class="p-3 rounded-lg ${
type === 'success' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}">${message}</div>`;
validationStatus.classList.remove('hidden');
}
// Validate Button Click
validateBtn.addEventListener('click', () => {
validateYAML();
});
// Convert Button Click
convertBtn.addEventListener('click', () => {
if (!isValidYAML) return;
const portainerTemplate = generatePortainerTemplate(composeData);
const yachtTemplate = generateYachtTemplate(composeData);
portainerOutput.value = JSON.stringify(portainerTemplate, null, 2);
yachtOutput.value = JSON.stringify(yachtTemplate, null, 2);
});
// Generate Portainer Template
function generatePortainerTemplate(data) {
const services = data.services || {};
const template = {
version: "3.8",
templates: []
};
Object.entries(services).forEach(([name, service]) => {
const templateItem = {
type: 1,
title: name,
name: name,
description: `Service: ${name}`,
logo: "https://portainer.github.io/logos/nginx.png",
image: service.image || "",
ports: [],
volumes: [],
env: []
};
// Handle ports
if (service.ports) {
templateItem.ports = service.ports.map(port => {
const portStr = port.toString();
const [host, container] = portStr.split(':');
return {
host: host || container,
container: container || host
};
});
}
// Handle volumes
if (service.volumes) {
templateItem.volumes = service.volumes.map(vol => ({
container: vol.toString(),
bind: vol.toString()
}));
}
// Handle environment
if (service.environment) {
if (Array.isArray(service.environment)) {
templateItem.env = service.environment.map(env => {
const [name, value] = env.split('=');
return { name, value: value || "" };
});
} else if (typeof service.environment === 'object') {
templateItem.env = Object.entries(service.environment).map(([name, value]) => ({
name,
value: value || ""
}));
}
}
template.templates.push(templateItem);
});
return template;
}
// Generate Yacht Template
function generateYachtTemplate(data) {
const services = data.services || {};
const template = {
name: "Converted Stack",
description: "Converted from docker-compose.yml",
version: "1.0",
services: []
};
Object.entries(services).forEach(([name, service]) => {
const serviceItem = {
name: name,
image: service.image || "",
ports: [],
volumes: [],
environment: {},
restart: service.restart || "unless-stopped"
};
// Handle ports
if (service.ports) {
serviceItem.ports = service.ports.map(port => port.toString());
}
// Handle volumes
if (service.volumes) {
serviceItem.volumes = service.volumes.map(vol => vol.toString());
}
// Handle environment
if (service.environment) {
if (Array.isArray(service.environment)) {
service.environment.forEach(env => {
const [key, value] = env.split('=');
serviceItem.environment[key] = value || "";
});
} else if (typeof service.environment === 'object') {
serviceItem.environment = service.environment;
}
}
template.services.push(serviceItem);
});
return template;
}
// Copy to Clipboard
function copyToClipboard(text, button) {
navigator.clipboard.writeText(text).then(() => {
const originalHTML = button.innerHTML;
button.innerHTML = '<i data-feather="check" class="w-4 h-4 mr-2"></i>Copied!';
button.classList.add('bg-green-600');
setTimeout(() => {
button.innerHTML = originalHTML;
button.classList.remove('bg-green-600');
feather.replace();
}, 2000);
});
}
copyPortainer.addEventListener('click', () => {
copyToClipboard(portainerOutput.value, copyPortainer);
});
copyYacht.addEventListener('click', () => {
copyToClipboard(yachtOutput.value, copyYacht);
});
// Download JSON
function downloadJSON(content, filename) {
const blob = new Blob([content], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
downloadPortainer.addEventListener('click', () => {
downloadJSON(portainerOutput.value, 'portainer-template.json');
});
downloadYacht.addEventListener('click', () => {
downloadJSON(yachtOutput.value, 'yacht-template.json');
});
// Auto-validate on input
let validationTimeout;
composeInput.addEventListener('input', () => {
clearTimeout(validationTimeout);
validationTimeout = setTimeout(() => {
if (composeInput.value.trim()) {
validateYAML();
}
}, 1000);
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment