
Frappe Ops Website Deploy
- 19 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/frappe_claude_skill_package
Helps with devops & ci/cd tasks.
About
frappe-ops-website-deploy is a Claude Code skill for devops & ci/cd. It helps solo builders move faster with AI-assisted development.
- frappe-ops-website-deploy
- DevOps & CI/CD
- AI-coding skill
Frappe Ops Website Deploy by the numbers
- 19 all-time installs (skills.sh)
- Ranked #921 of 1,435 DevOps & CI/CD skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/frappe_claude_skill_package --skill frappe-ops-website-deployAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 19 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/frappe_claude_skill_package ↗ |
What it does
Helps with devops & ci/cd tasks.
Files
Deploy Websites on ERPNext/Frappe
Patterns for deploying static HTML/CSS websites to ERPNext v15/v16 using Web Pages, Page Builder, and the REST API.
---
Critical: ERPNext v16 Does NOT Render main_section
In Frappe v16, the main_section field on a Web Page is stored but not rendered in the browser — even with content_type: "HTML" or dynamic_template: 1. The Page Builder (page_blocks) is the primary rendering mechanism.
You must use `page_blocks` with a custom Web Template to render HTML content.
---
Decision Tree
What do you need?
├── Deploy HTML pages to ERPNext
│ ├── Step 1: Create "Raw HTML Section" Web Template (one-time)
│ ├── Step 2: Create Web Pages with page_blocks
│ └── Step 3: Configure Website Settings
│
├── Add a forum / discussion system
│ └── Use Frappe's built-in Discussion Topic/Reply + Discussions Web Template
│
├── Manage CSS
│ ├── Per-page CSS → Web Page `css` field
│ ├── Global CSS → Website Settings `head_html`
│ └── WARNING: Never stack !important overrides (see CSS Management)
│
└── Configure navigation
└── Website Settings: top_bar_items, footer_items, brand_html, home_page---
Step 1: Create the Raw HTML Section Web Template
This is a one-time setup. The template accepts raw HTML and renders it as-is.
POST /api/resource/Web%20Template{
"name": "Raw HTML Section",
"type": "Section",
"template": "{{ values.html_content }}",
"fields": [
{
"fieldname": "html_content",
"fieldtype": "Text",
"label": "HTML Content"
}
]
}Important constraints:
- The
fieldtypemust be"Text"— Frappe rejects"Code"for Web Template fields - Allowed fieldtypes:
Attach Image,Check,Data,Int,Link,Select,Small Text,Text,Markdown Editor,Section Break,Column Break,Table Break - The template uses
{{ values.html_content }}(withvalues.prefix) to access field data
---
Step 2: Create Web Pages with Page Builder
Each page needs content_type: "Page Builder" and its HTML in page_blocks.
POST /api/resource/Web%20Page{
"title": "Page Title",
"route": "my-page",
"published": 1,
"show_title": 0,
"full_width": 1,
"content_type": "Page Builder",
"css": "<per-page CSS here>",
"page_blocks": [
{
"web_template": "Raw HTML Section",
"web_template_values": "{\"html_content\": \"<div>Your HTML here</div>\"}"
}
]
}Critical: `web_template_values` is a JSON string, not an object. Serialize it with json.dumps() before sending.
Updating an existing page
PUT /api/resource/Web%20Page/{url_encoded_name}To find a page by route:
GET /api/resource/Web%20Page?filters=[["route","=","my-page"]]&fields=["name"]---
Step 3: Configure Website Settings
PUT /api/resource/Website%20Settings/Website%20Settings{
"home_page": "home",
"brand_html": "<span style=\"...\">NL</span> My Brand",
"head_html": "<link href=\"fonts.css\" rel=\"stylesheet\">\n<style>/* global CSS */</style>",
"top_bar_items": [
{"label": "About", "url": "/about", "right": 0}
],
"footer_items": [
{"label": "About", "url": "/about"}
]
}head_html: Frappe Wrapper Fixes
Frappe wraps page content in several divs that add unwanted whitespace. Add these fixes to head_html:
<style>
.page-header-wrapper { display: none !important; }
.page-breadcrumbs { display: none !important; }
.page-content-wrapper { padding: 0 !important; margin: 0 !important; }
.page_content { padding: 0 !important; margin: 0 !important; }
.webpage-content { padding: 0 !important; margin: 0 !important; }
.web-page-content { padding: 0 !important; margin: 0 !important; max-width: none !important; }
.section.section-padding-top { padding-top: 0 !important; }
.section.section-padding-bottom { padding-bottom: 0 !important; }
.web-template-section { padding: 0 !important; margin: 0 !important; }
main { padding: 0 !important; margin: 0 !important; }
</style>These are the only !important overrides you should use — they target Frappe's own wrapper elements, not your content.
---
CSS Management
The golden rule: keep your mockup CSS intact
Use the original mockup CSS in each page's css field. Only add Frappe wrapper fixes in head_html. Do not layer !important overrides on top of your content CSS — this leads to cascading conflicts and unpredictable layouts.
Where CSS goes
| CSS Type | Where | Field |
|---|---|---|
| Mockup/page CSS | Per Web Page | css |
| Google Fonts, Frappe fixes | Website Settings | head_html |
| CSS variables (:root) | Website Settings | head_html |
What NOT to do
Never add broad !important overrides for content elements like .card, section, h2, etc. If spacing looks wrong, the cause is almost always a Frappe wrapper div — fix that specifically rather than overriding all your content styles.
---
Deploying from HTML Mockups
When converting a static HTML mockup to ERPNext Web Pages, follow this process:
1. Extract the body content
Strip everything outside the main content area — typically between </header> and <footer>. Remove the mockup's own nav and footer since Frappe provides its own via Website Settings.
2. Rewrite links
Replace .html file references with Frappe routes:
href="about.html" → href="/about"
href="index.html" → href="/"3. Handle images
Local img/ references won't work on ERPNext. Options:
- Upload images via Frappe File Manager and use the returned URL
- Use the File API:
POST /api/method/upload_file - Reference external image URLs
4. Deploy script pattern
See scripts/deploy.py for a complete deployment script. The key pattern:
import requests, json
def deploy_page(title, route, html_content, css):
data = {
"title": title,
"route": route,
"published": 1,
"show_title": 0,
"full_width": 1,
"content_type": "Page Builder",
"css": css,
"page_blocks": [{
"web_template": "Raw HTML Section",
"web_template_values": json.dumps({"html_content": html_content})
}]
}
# Create or update (check 409 conflict for existing pages)
resp = requests.post(f"{BASE_URL}/api/resource/Web%20Page",
headers=HEADERS, json=data)
if resp.status_code == 409:
# Find and update existing
...---
Forum Integration with Frappe Discussions
Frappe has built-in DocTypes for discussions that can be embedded on any Web Page.
Available DocTypes
- Discussion Topic — a thread/topic linked to a reference document
- Discussion Reply — a reply within a topic
Creating a forum page
Use the built-in "Discussions" Web Template as a page block:
{
"page_blocks": [
{
"web_template": "Raw HTML Section",
"web_template_values": "{\"html_content\": \"<section><div class=\\\"container\\\"><h1>Forum</h1></div></section>\"}"
},
{
"web_template": "Discussions",
"web_template_values": "{\"title\": \"Discussies\", \"cta_title\": \"Nieuw onderwerp\", \"docname\": \"forum\", \"single_thread\": 0}"
}
]
}Discussions Web Template fields
| Field | Type | Purpose |
|---|---|---|
title | Data | Section heading |
cta_title | Data | Button text for new topic |
docname | Link | Web Page to attach discussions to |
single_thread | Check | 0 = multiple topics, 1 = single thread |
Managing topics via API
POST /api/resource/Discussion%20Topic
{"subject": "My topic", "reference_doctype": "Web Page", "reference_docname": "forum"}
POST /api/resource/Discussion%20Reply
{"topic": "TOPIC0001", "reply": "My reply text"}---
Authentication
All API calls require authentication via token header:
Authorization: token {api_key}:{api_secret}Generate API keys in ERPNext: User Settings → API Access → Generate Keys.
Never store API keys in skill files, SKILL.md, or commit them to git. Pass them as environment variables or read from a secure config.
---
Troubleshooting
Page is blank / main_section not rendering
You're hitting the v16 Page Builder issue. Switch to content_type: "Page Builder" with page_blocks. See Step 2.
White bar above content
Frappe's .page-header-wrapper and .page-breadcrumbs add empty space. Hide them via head_html. See Step 3.
Web Template creation fails with fieldtype error
Use "Text" not "Code" for the fieldtype. Frappe Web Templates only allow a subset of fieldtypes.
CSS looks wrong / spacing is off
Check if Frappe's .section.section-padding-top or .page-content-wrapper are adding padding. Fix those specifically — don't override your content CSS with !important.
Grid layouts collapse to single column
If your mockup CSS has media queries that override grid columns, those will apply on ERPNext too. Check the rendered CSS for conflicting media query rules.
Images don't show
Local img/ paths from mockups won't resolve. Upload files to ERPNext or use absolute URLs.
Frappe Page Wrapper Elements
When Frappe renders a Web Page, it wraps the content in several nested divs. These are the elements between the navbar and your actual content, and they often add unwanted padding/margin.
Element hierarchy (outside → inside)
<div id="page-{name}" data-path="{route}" data-doctype="Web Page">
<div class="page-content-wrapper">
<div class="page-breadcrumbs">
<!-- empty if no breadcrumbs configured -->
</div>
<main class="">
<div class="page-header-wrapper">
<div class="page-header">
<!-- page title if show_title=1, padding: 56px 0 36px by default -->
</div>
</div>
<div class="page_content">
<section class="section section-padding-top section-padding-bottom"
data-section-template="Raw HTML Section">
<div class="container">
<!-- YOUR CONTENT HERE -->
</div>
</section>
</div>
</main>
</div>
</div>Elements to hide/reset
| Element | Default behavior | Fix |
|---|---|---|
.page-header-wrapper | 56px top padding, border | display: none |
.page-breadcrumbs | Empty space | display: none |
.page-content-wrapper | May have padding | padding: 0; margin: 0 |
.page_content | May have padding | padding: 0; margin: 0 |
.section.section-padding-top | Adds top padding | padding-top: 0 |
.section.section-padding-bottom | Adds bottom padding | padding-bottom: 0 |
.web-template-section | May add spacing | padding: 0; margin: 0 |
Where to apply fixes
Put these in Website Settings → head_html as a <style> block. This applies globally to all Web Pages. Do NOT put them in per-page CSS — they're Frappe structural fixes, not content styles.
"""
Deploy HTML mockup pages to ERPNext v16 as Web Pages using Page Builder.
Usage:
python deploy.py --base-url https://your-site.com --api-key "key:secret" --mockup-dir ./mockup
This script:
1. Creates a "Raw HTML Section" Web Template (if it doesn't exist)
2. Reads HTML files from the mockup directory
3. Extracts the body content (between </header> and <footer>)
4. Rewrites .html links to Frappe routes
5. Creates/updates Web Pages with page_blocks
6. Configures Website Settings (navbar, footer, homepage)
"""
import json
import re
import os
import sys
import argparse
import urllib.parse
try:
import requests
except ImportError:
print("Error: 'requests' package required. Install with: pip install requests")
sys.exit(1)
def make_headers(api_key):
return {
"Authorization": f"token {api_key}",
"Content-Type": "application/json"
}
def ensure_web_template(base_url, headers):
"""Create the Raw HTML Section Web Template if it doesn't exist."""
resp = requests.get(
f"{base_url}/api/resource/Web%20Template/Raw HTML Section",
headers=headers, timeout=10
)
if resp.status_code == 200:
print("Web Template 'Raw HTML Section' already exists")
return True
resp = requests.post(
f"{base_url}/api/resource/Web%20Template",
headers=headers,
json={
"name": "Raw HTML Section",
"type": "Section",
"template": "{{ values.html_content }}",
"fields": [{
"fieldname": "html_content",
"fieldtype": "Text",
"label": "HTML Content"
}]
},
timeout=15
)
if resp.status_code == 200:
print("Created Web Template 'Raw HTML Section'")
return True
else:
print(f"Failed to create Web Template: {resp.status_code} {resp.text[:200]}")
return False
def extract_body(html):
"""Extract content between </header> and <footer>."""
match = re.search(r'</header>\s*', html, re.DOTALL)
if match:
html = html[match.end():]
else:
match = re.search(r'<body[^>]*>\s*', html, re.DOTALL)
if match:
html = html[match.end():]
match = re.search(r'<footer', html, re.DOTALL)
if match:
html = html[:match.start()]
html = re.sub(r'</body>\s*</html>\s*$', '', html, flags=re.DOTALL)
return html.strip()
def rewrite_links(html, link_map):
"""Replace .html references with Frappe routes."""
for old, new in link_map.items():
html = html.replace(f'href="{old}"', f'href="{new}"')
html = html.replace(f"href='{old}'", f"href='{new}'")
return html
def extract_inline_styles(html):
"""Extract <style> tags from the HTML."""
styles = re.findall(r'<style>(.*?)</style>', html, re.DOTALL)
return '\n'.join(styles)
def find_page_by_route(base_url, headers, route):
"""Find a Web Page by its route."""
resp = requests.get(
f'{base_url}/api/resource/Web%20Page?filters=[["route","=","{route}"]]&fields=["name"]',
headers=headers, timeout=10
)
if resp.status_code == 200 and resp.json().get("data"):
return resp.json()["data"][0]["name"]
return None
def deploy_page(base_url, headers, title, route, html_content, css):
"""Create or update a Web Page."""
data = {
"title": title,
"route": route,
"published": 1,
"show_title": 0,
"full_width": 1,
"content_type": "Page Builder",
"css": css,
"page_blocks": [{
"web_template": "Raw HTML Section",
"web_template_values": json.dumps({"html_content": html_content})
}]
}
existing = find_page_by_route(base_url, headers, route)
if existing:
resp = requests.put(
f"{base_url}/api/resource/Web%20Page/{urllib.parse.quote(existing)}",
headers=headers, json=data, timeout=30
)
if resp.status_code == 200:
print(f" Updated: {title} -> /{route}")
return True
else:
resp = requests.post(
f"{base_url}/api/resource/Web%20Page",
headers=headers, json=data, timeout=30
)
if resp.status_code == 200:
print(f" Created: {title} -> /{route}")
return True
print(f" FAILED: {title} -> /{route}: {resp.status_code} {resp.text[:200]}")
return False
def update_website_settings(base_url, headers, home_page, brand_html, nav_items, footer_items):
"""Configure Website Settings."""
head_html = '''<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<style>
.page-header-wrapper { display: none !important; }
.page-breadcrumbs { display: none !important; }
.page-content-wrapper { padding: 0 !important; margin: 0 !important; }
.page_content { padding: 0 !important; margin: 0 !important; }
.webpage-content { padding: 0 !important; margin: 0 !important; }
.web-page-content { padding: 0 !important; margin: 0 !important; max-width: none !important; }
.section.section-padding-top { padding-top: 0 !important; }
.section.section-padding-bottom { padding-bottom: 0 !important; }
.web-template-section { padding: 0 !important; margin: 0 !important; }
main { padding: 0 !important; margin: 0 !important; }
body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif !important; }
</style>'''
data = {
"home_page": home_page,
"brand_html": brand_html,
"head_html": head_html,
"top_bar_items": nav_items,
"footer_items": footer_items,
}
resp = requests.put(
f"{base_url}/api/resource/Website%20Settings/Website%20Settings",
headers=headers, json=data, timeout=15
)
if resp.status_code == 200:
print("Website Settings updated")
else:
print(f"Website Settings FAILED: {resp.status_code} {resp.text[:200]}")
def main():
parser = argparse.ArgumentParser(description="Deploy HTML mockup to ERPNext")
parser.add_argument("--base-url", required=True, help="ERPNext instance URL")
parser.add_argument("--api-key", required=True, help="API key in format key:secret")
parser.add_argument("--mockup-dir", required=True, help="Path to mockup directory")
parser.add_argument("--css-file", help="Path to CSS file (default: mockup-dir/css/style.css)")
parser.add_argument("--pages-json", help="Path to pages config JSON")
args = parser.parse_args()
headers = make_headers(args.api_key)
css_path = args.css_file or os.path.join(args.mockup_dir, "css", "style.css")
# Read CSS
with open(css_path, "r", encoding="utf-8") as f:
css = f.read()
print(f"CSS loaded: {len(css)} chars")
# Ensure Web Template exists
if not ensure_web_template(args.base_url, headers):
sys.exit(1)
# Load pages config or auto-discover
if args.pages_json:
with open(args.pages_json, "r") as f:
pages = json.load(f)
else:
# Auto-discover HTML files
pages = []
for fname in sorted(os.listdir(args.mockup_dir)):
if fname.endswith(".html"):
route = fname.replace(".html", "")
if route == "index":
route = "home"
title = route.replace("-", " ").title()
pages.append({"file": fname, "title": title, "route": route})
# Build link map
link_map = {}
for page in pages:
old = page["file"]
new = "/" if page["route"] == "home" else f"/{page['route']}"
link_map[old] = new
# Deploy pages
print(f"\nDeploying {len(pages)} pages...")
success = 0
for page in pages:
filepath = os.path.join(args.mockup_dir, page["file"])
if not os.path.exists(filepath):
print(f" SKIP: {page['file']} not found")
continue
with open(filepath, "r", encoding="utf-8") as f:
html_full = f.read()
body = extract_body(html_full)
body = rewrite_links(body, link_map)
inline_styles = extract_inline_styles(html_full)
if inline_styles:
body = f"<style>{inline_styles}</style>\n{body}"
if deploy_page(args.base_url, headers, page["title"], page["route"], body, css):
success += 1
print(f"\n{success}/{len(pages)} pages deployed")
if __name__ == "__main__":
main()