There's a specific moment when you realize you waited too long. For me it was a Tuesday at 11pm — three VS Code windows open, HAProxy 2.8 running in Docker, and a validation error I couldn't figure out for the life of me. I open the syntax highlighting extension I had installed — the only one that existed in the marketplace — and the last commit was from 2019.
- Five years without a single change. HAProxy went from version 2.0 to 3.1 in that time. They added
log-format-sd, rewrote the behavior ofoption http-server-close, deprecated entire directives. And the extension just sat there, frozen in time like a digital mummy, knowing absolutely nothing.
That Tuesday I said: enough. If nobody else is going to do it, I will.
Why HAProxy Deserves a Decent Extension (and Why Almost Nobody Talks About This)
Before I get into the code, I need to give you some context — because I know 80% of devs reading this work with Nginx or Traefik and think HAProxy is "that old thing banks use." And yeah, they're right about the second part — banks use it, telcos use it, crypto exchanges use it. But not because it's old. Because it's brutally efficient and has the most expressive configuration model that exists for a proxy.
I use it every day. At work to load balance traffic between microservices. In my homelab I run a stack with HAProxy at the front, three internal service backends, rate limiting per IP, ACLs that distinguish LAN traffic from VPN traffic, and health checks every five seconds. All in a .cfg file that's over 400 lines long.
The problem is that .cfg file is basically plain text to any editor. No schema, no LSP, nothing. You type frontend my-frontend and the editor has no idea that inside that block there are specific directives that don't exist in any other context. You type backend and it doesn't suggest balance roundrobin versus balance leastconn. You use a directive that was deprecated in 2.6 and nobody warns you.
That's exactly what I went to fix.
The Architecture: It Wasn't as Simple as "a JSON File With Keywords"
The first week I thought this was going to be easy. "I'll throw all the keywords into a TextMate grammar file, give them some colors, done." That naivety lasted exactly until I opened the full HAProxy configuration spec.
HAProxy has a section-based architecture: global, defaults, frontend, backend, listen, peers, resolvers, userlist, cache, program. And each section accepts a different subset of directives. bind only exists in frontend and listen. server only exists in backend and listen. mode exists in several but with different allowed values depending on context.
You can't solve that with TextMate grammars. That needs a real Language Server Protocol.
// src/server/haproxy-language-server.ts
// The heart of the LSP — we initialize the capabilities we're going to support
import {
createConnection,
TextDocuments,
ProposedFeatures,
CompletionItem,
CompletionItemKind,
TextDocumentSyncKind,
} from 'vscode-languageserver/node';
import { TextDocument } from 'vscode-languageserver-textdocument';
import { HaproxyParser } from './parser/haproxy-parser';
import { CompletionProvider } from './providers/completion-provider';
import { DiagnosticsProvider } from './providers/diagnostics-provider';
const connection = createConnection(ProposedFeatures.all);
const documents = new TextDocuments(TextDocument);
// When the client (VS Code) asks for completions, we need to know
// which section the cursor is in to give contextual suggestions
connection.onInitialize(() => ({
capabilities: {
textDocumentSync: TextDocumentSyncKind.Incremental,
completionProvider: {
resolveProvider: true, // enable detail view for each item
triggerCharacters: [' ', '\t'] // autocomplete on space or tab
},
definitionProvider: true, // go-to-definition for backends
codeActionProvider: true, // quickfix for deprecated directives
diagnosticProvider: {
interFileDependencies: false,
workspaceDiagnostics: false
}
}
}));
The parser was the most complicated part and the one that took the most time. HAProxy doesn't have a strict format like YAML or JSON — it's its own configuration language with optional indentation, # comments, line continuation with \, and context semantics that depend entirely on which section you're in.
// src/server/parser/haproxy-parser.ts
// Parser that understands section context — the key to everything else
export interface ParsedSection {
type: SectionType; // 'global' | 'defaults' | 'frontend' | 'backend' | etc.
name: string | null; // section name (null for global/defaults)
startLine: number;
endLine: number;
directives: ParsedDirective[];
}
export class HaproxyParser {
parse(text: string): ParsedSection[] {
const lines = text.split('\n');
const sections: ParsedSection[] = [];
let currentSection: ParsedSection | null = null;
lines.forEach((line, lineNumber) => {
const trimmed = line.trim();
// Skip comments and empty lines
if (trimmed.startsWith('#') || trimmed === '') return;
// Detect the start of a new section
const sectionMatch = trimmed.match(
/^(global|defaults|frontend|backend|listen|peers|resolvers|userlist|cache|program)\s*(\S*)$/
);
if (sectionMatch) {
// Close the previous section if one exists
if (currentSection) {
currentSection.endLine = lineNumber - 1;
sections.push(currentSection);
}
// Open the new section with its type and name
currentSection = {
type: sectionMatch[1] as SectionType,
name: sectionMatch[2] || null,
startLine: lineNumber,
endLine: -1, // filled in when we find the next section
directives: []
};
return;
}
// If we're inside a section, parse the directive
if (currentSection) {
currentSection.directives.push(
this.parseDirective(trimmed, lineNumber)
);
}
});
// Don't forget to close the last section
if (currentSection) {
(currentSection as ParsedSection).endLine = lines.length - 1;
sections.push(currentSection as ParsedSection);
}
return sections;
}
}
Contextual Autocomplete: The Feature That Changed Everything
Once the parser was working, contextual autocomplete was almost natural. The idea is simple: when VS Code asks for completions, you ask the parser "which section is the cursor in?" and filter suggestions based on that.
Are you in a frontend? I offer you bind, mode, acl, use_backend, default_backend, option, timeout... but NOT server or balance, which belong in backend. Are you in global? I offer maxconn, daemon, log, ssl-default-bind-options... and nothing else.
This sounds trivial but the difference in actual use is massive. In a complex HAProxy config with 10 sections, autocomplete that doesn't understand context dumps 200 mixed options on you. Mine gives you exactly the ones that apply to where your cursor is sitting.
But the feature I'm most proud of is go-to-definition for backends. If your frontend has default_backend my-api and you hit F12, it jumps straight to the backend my-api section. Sounds simple. But when your config is 400 lines with 15 backends, that F12 saves you literal minutes of scrolling every single day.
Multi-Version Validation: The Mess of HAProxy 2.4 Through 3.1
This is where things got a little crazy. HAProxy changed quite a bit between versions. Things that were valid in 2.4 got deprecated in 2.6, and others were flat-out removed in 3.0. If the extension doesn't know which version you're running, diagnostics are going to be full of false positives or false negatives.
The solution was adding a VS Code setting where the user declares their HAProxy version:
// .vscode/settings.json — per-workspace configuration
{
"gmm-haproxy.version": "2.8",
"gmm-haproxy.strictMode": true
}
And on the server side, I maintain a registry of which directives exist in which version, which ones were deprecated and when, and which ones were removed:
// src/server/schema/version-registry.ts
// Directive registry by version — this is where the hard HAProxy knowledge lives
export interface DirectiveInfo {
name: string;
sections: SectionType[]; // which sections it's valid in
since: string; // version it was introduced
deprecated?: string; // version it was deprecated
removed?: string; // version it was removed
replacement?: string; // recommended directive if deprecated
description: string;
}
// Real examples of directives with their version history
export const DIRECTIVE_REGISTRY: DirectiveInfo[] = [
{
name: 'option forwardfor',
sections: ['frontend', 'backend', 'listen', 'defaults'],
since: '1.3',
description: 'Adds the X-Forwarded-For header with the real client IP'
},
{
name: 'reqadd',
sections: ['frontend', 'listen', 'backend'],
since: '1.3',
deprecated: '2.2', // deprecated in 2.2
removed: '3.0', // removed in 3.0
replacement: 'http-request set-header', // the modern alternative
description: '[DEPRECATED] Used to add headers to the request. Use http-request set-header instead'
},
{
name: 'http-request set-header',
sections: ['frontend', 'backend', 'listen'],
since: '2.2',
description: 'Modifies or adds HTTP headers to the incoming request'
}
// ... and so on for ~400 more directives
];
When the DiagnosticsProvider detects you're using reqadd in a config with version 3.0, it throws an error with a quickfix included: "Replace with http-request set-header". One click and you're done.
The Mistakes I Made (That You'll Make Too If You Build Something Like This)
Mistake 1: Underestimating LSP startup time. The first prototype re-parsed the entire document on every keystroke. On large files, the lag was noticeable. The fix was incremental parsing — you only re-parse the sections that actually changed.
Mistake 2: Not handling incomplete configs. While you're typing, your config is broken most of the time. The parser has to be error-tolerant and produce a useful partial AST instead of crashing. It took two extra weeks to get decent error recovery in place.
Mistake 3: Assuming the VS Code API is stable. Between the version I read in the docs and the version I had installed, there were subtle differences in how onDocumentDiagnostic worked. Lesson learned: always test against the minimum version declared in engines.vscode in your package.json.
Mistake 4: Not having a corpus of real configs to test against. I put together a directory of anonymized real configs from my homelab and from work. That alone found more bugs than any unit test I wrote.
The Result: What I Use Every Day
Today gmm-haproxy-vscode has:
- Contextual syntax highlighting — visually distinguishes section names, directives, values, ACL names, and comments
- Custom LSP with section-filtered autocomplete
- Real-time validation against the schema for the declared version (2.4, 2.6, 2.8, 3.0, 3.1)
-
Go-to-definition for backends referenced in
use_backendanddefault_backend - Automatic quickfix for deprecated directives
- Hover documentation — hover over any directive and it explains what it does
- Snippets for common structures: basic frontend, backend with health check, rate limiting ACL
Last week I used it to refactor my entire homelab config from HAProxy 2.8 to 3.1. Without the extension, that process would have been a full Sunday of reading changelogs and manually hunting down obsolete directives. With the extension, it took two hours — most of that time spent applying quickfixes.
Why I Did This Instead of Just Switching to Nginx
Someone's going to ask, so I'll answer it upfront. HAProxy does things Nginx doesn't do as well. HAProxy's ACL model is extraordinarily expressive. You can make routing decisions based on headers, paths, source IPs, time of day, backend weight, number of active connections — all in the config file, no scripting. The health checking is more granular. The stats model via socket is more complete.
Is it the right tool for everything? No. For a small personal project, Traefik or Caddy are more comfortable. But when you have real traffic and need fine-grained control, HAProxy is still king. And the king deserves an extension that isn't a 2019 zombie.
If you want to try gmm-haproxy-vscode, you'll find it in the VS Code Marketplace. If you find a bug or a directive it doesn't recognize — and you will, the HAProxy spec is enormous — open an issue. I actively maintain it because I use it every day. That's the best guarantee I can give you.
Top comments (0)