Introduction
Not every problem needs a SaaS subscription. Sometimes the best solution is a focused internal tool built exactly for your needs. After building several internal applications—from inventory management to deployment dashboards—I’ve developed patterns that work.
This guide covers building practical internal web applications: choosing the right stack, designing for maintainability, and deploying without complexity.
When to Build vs. Buy
Build internal tools when:
| Build | Buy |
|---|---|
| Workflow is unique to your organization | Standard business process |
| Integration with internal systems is critical | Standalone functionality |
| Data sensitivity requires full control | Acceptable vendor access |
| Long-term cost of SaaS exceeds development | One-time or short-term need |
The key question: Will this tool save more time than it takes to build and maintain?
Technology Stack
For internal tools, simplicity wins:
Frontend: HTML + HTMX (or Vue/React for complex UIs)
Backend: Python (Flask/FastAPI) or Go
Database: PostgreSQL
Auth: LDAP/SSO integration
Deployment: Docker + internal infrastructure
Why This Stack?
- Python: Most infrastructure teams already know it
- Flask/FastAPI: Minimal boilerplate, easy to extend
- HTMX: Modern interactivity without JavaScript complexity
- PostgreSQL: Reliable, well-understood, no licensing fees
Project Structure
internal-app/
├── app/
│ ├── __init__.py
│ ├── models/
│ │ ├── __init__.py
│ │ └── inventory.py
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── api.py
│ │ └── views.py
│ ├── services/
│ │ ├── __init__.py
│ │ └── inventory.py
│ ├── templates/
│ │ ├── base.html
│ │ └── inventory/
│ └── static/
│ ├── css/
│ └── js/
├── config.py
├── requirements.txt
├── Dockerfile
└── docker-compose.yml
Building a Server Inventory App
Let’s build a practical example: a server inventory system that tracks hardware, software, and ownership.
Database Models
# app/models/inventory.py
from datetime import datetime
from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Text
from sqlalchemy.orm import relationship
from app import db
class Server(db.Model):
__tablename__ = 'servers'
id = Column(Integer, primary_key=True)
hostname = Column(String(255), unique=True, nullable=False)
ip_address = Column(String(45))
environment = Column(String(50)) # production, staging, dev
role = Column(String(100)) # web, database, cache
os = Column(String(100))
cpu_cores = Column(Integer)
memory_gb = Column(Integer)
disk_gb = Column(Integer)
location = Column(String(100)) # datacenter or cloud region
owner = Column(String(100)) # team or individual
notes = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, onupdate=datetime.utcnow)
# Relationships
software = relationship('InstalledSoftware', backref='server')
tags = relationship('ServerTag', backref='server')
def to_dict(self):
return {
'id': self.id,
'hostname': self.hostname,
'ip_address': self.ip_address,
'environment': self.environment,
'role': self.role,
'os': self.os,
'specs': f"{self.cpu_cores} CPU, {self.memory_gb}GB RAM",
'owner': self.owner,
'location': self.location
}
class InstalledSoftware(db.Model):
__tablename__ = 'installed_software'
id = Column(Integer, primary_key=True)
server_id = Column(Integer, ForeignKey('servers.id'), nullable=False)
name = Column(String(100), nullable=False)
version = Column(String(50))
installed_at = Column(DateTime)
class ServerTag(db.Model):
__tablename__ = 'server_tags'
id = Column(Integer, primary_key=True)
server_id = Column(Integer, ForeignKey('servers.id'), nullable=False)
tag = Column(String(50), nullable=False)
Service Layer
# app/services/inventory.py
from typing import List, Optional
from sqlalchemy import or_
from app.models.inventory import Server, InstalledSoftware, ServerTag
from app import db
class InventoryService:
"""Business logic for server inventory."""
@staticmethod
def search_servers(
query: str = None,
environment: str = None,
role: str = None,
owner: str = None
) -> List[Server]:
"""Search servers with filters."""
filters = []
if query:
search = f"%{query}%"
filters.append(or_(
Server.hostname.ilike(search),
Server.ip_address.ilike(search),
Server.notes.ilike(search)
))
if environment:
filters.append(Server.environment == environment)
if role:
filters.append(Server.role == role)
if owner:
filters.append(Server.owner == owner)
return Server.query.filter(*filters).order_by(Server.hostname).all()
@staticmethod
def get_server(server_id: int) -> Optional[Server]:
return Server.query.get(server_id)
@staticmethod
def get_by_hostname(hostname: str) -> Optional[Server]:
return Server.query.filter_by(hostname=hostname).first()
@staticmethod
def create_server(data: dict) -> Server:
server = Server(**data)
db.session.add(server)
db.session.commit()
return server
@staticmethod
def update_server(server_id: int, data: dict) -> Optional[Server]:
server = Server.query.get(server_id)
if not server:
return None
for key, value in data.items():
if hasattr(server, key):
setattr(server, key, value)
db.session.commit()
return server
@staticmethod
def delete_server(server_id: int) -> bool:
server = Server.query.get(server_id)
if not server:
return False
db.session.delete(server)
db.session.commit()
return True
@staticmethod
def get_statistics() -> dict:
"""Get inventory statistics."""
return {
'total_servers': Server.query.count(),
'by_environment': db.session.query(
Server.environment, db.func.count(Server.id)
).group_by(Server.environment).all(),
'by_role': db.session.query(
Server.role, db.func.count(Server.id)
).group_by(Server.role).all(),
'total_cpu': db.session.query(
db.func.sum(Server.cpu_cores)
).scalar() or 0,
'total_memory': db.session.query(
db.func.sum(Server.memory_gb)
).scalar() or 0
}
API Routes
# app/routes/api.py
from flask import Blueprint, request, jsonify
from app.services.inventory import InventoryService
api = Blueprint('api', __name__, url_prefix='/api')
@api.route('/servers', methods=['GET'])
def list_servers():
"""List servers with optional filters."""
servers = InventoryService.search_servers(
query=request.args.get('q'),
environment=request.args.get('environment'),
role=request.args.get('role'),
owner=request.args.get('owner')
)
return jsonify([s.to_dict() for s in servers])
@api.route('/servers/<int:server_id>', methods=['GET'])
def get_server(server_id):
"""Get single server details."""
server = InventoryService.get_server(server_id)
if not server:
return jsonify({'error': 'Not found'}), 404
return jsonify(server.to_dict())
@api.route('/servers', methods=['POST'])
def create_server():
"""Create new server."""
data = request.json
# Validate required fields
required = ['hostname', 'environment', 'role']
if not all(field in data for field in required):
return jsonify({'error': 'Missing required fields'}), 400
# Check for duplicates
if InventoryService.get_by_hostname(data['hostname']):
return jsonify({'error': 'Hostname already exists'}), 409
server = InventoryService.create_server(data)
return jsonify(server.to_dict()), 201
@api.route('/servers/<int:server_id>', methods=['PUT'])
def update_server(server_id):
"""Update server."""
server = InventoryService.update_server(server_id, request.json)
if not server:
return jsonify({'error': 'Not found'}), 404
return jsonify(server.to_dict())
@api.route('/servers/<int:server_id>', methods=['DELETE'])
def delete_server(server_id):
"""Delete server."""
if InventoryService.delete_server(server_id):
return '', 204
return jsonify({'error': 'Not found'}), 404
@api.route('/stats', methods=['GET'])
def get_stats():
"""Get inventory statistics."""
return jsonify(InventoryService.get_statistics())
Frontend with HTMX
<!-- app/templates/base.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Server Inventory{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
</head>
<body>
<nav class="navbar">
<a href="/" class="nav-brand">Server Inventory</a>
<div class="nav-links">
<a href="/servers">Servers</a>
<a href="/dashboard">Dashboard</a>
</div>
<div class="nav-user">
{{ current_user.username }}
</div>
</nav>
<main class="container">
{% block content %}{% endblock %}
</main>
</body>
</html>
<!-- app/templates/inventory/list.html -->
{% extends "base.html" %}
{% block content %}
<div class="page-header">
<h1>Servers</h1>
<button class="btn btn-primary"
hx-get="/servers/new"
hx-target="#modal-container">
Add Server
</button>
</div>
<div class="filters">
<input type="search"
name="q"
placeholder="Search servers..."
hx-get="/servers/search"
hx-trigger="keyup changed delay:300ms"
hx-target="#server-list">
<select name="environment"
hx-get="/servers/search"
hx-trigger="change"
hx-target="#server-list"
hx-include="[name='q']">
<option value="">All Environments</option>
<option value="production">Production</option>
<option value="staging">Staging</option>
<option value="development">Development</option>
</select>
</div>
<div id="server-list">
{% include "inventory/_server_table.html" %}
</div>
<div id="modal-container"></div>
{% endblock %}
<!-- app/templates/inventory/_server_table.html -->
<table class="data-table">
<thead>
<tr>
<th>Hostname</th>
<th>IP Address</th>
<th>Environment</th>
<th>Role</th>
<th>Specs</th>
<th>Owner</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for server in servers %}
<tr>
<td>
<a href="/servers/{{ server.id }}">{{ server.hostname }}</a>
</td>
<td>{{ server.ip_address }}</td>
<td>
<span class="badge badge-{{ server.environment }}">
{{ server.environment }}
</span>
</td>
<td>{{ server.role }}</td>
<td>{{ server.specs }}</td>
<td>{{ server.owner }}</td>
<td>
<button class="btn btn-sm"
hx-get="/servers/{{ server.id }}/edit"
hx-target="#modal-container">
Edit
</button>
<button class="btn btn-sm btn-danger"
hx-delete="/api/servers/{{ server.id }}"
hx-confirm="Delete {{ server.hostname }}?"
hx-target="closest tr"
hx-swap="outerHTML">
Delete
</button>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="empty-state">No servers found</td>
</tr>
{% endfor %}
</tbody>
</table>
Authentication with LDAP
# app/auth.py
import ldap
from flask import current_app
from functools import wraps
class LDAPAuth:
"""LDAP authentication for internal users."""
def __init__(self, app=None):
if app:
self.init_app(app)
def init_app(self, app):
self.ldap_server = app.config['LDAP_SERVER']
self.ldap_base_dn = app.config['LDAP_BASE_DN']
self.ldap_user_dn = app.config['LDAP_USER_DN']
def authenticate(self, username: str, password: str) -> dict | None:
"""Authenticate user against LDAP."""
try:
conn = ldap.initialize(self.ldap_server)
conn.set_option(ldap.OPT_REFERRALS, 0)
user_dn = f"uid={username},{self.ldap_user_dn}"
conn.simple_bind_s(user_dn, password)
# Fetch user info
result = conn.search_s(
user_dn,
ldap.SCOPE_BASE,
'(objectClass=*)',
['cn', 'mail', 'memberOf']
)
if result:
_, attrs = result[0]
return {
'username': username,
'name': attrs.get('cn', [b''])[0].decode(),
'email': attrs.get('mail', [b''])[0].decode(),
'groups': [g.decode() for g in attrs.get('memberOf', [])]
}
return None
except ldap.INVALID_CREDENTIALS:
return None
except ldap.LDAPError as e:
current_app.logger.error(f"LDAP error: {e}")
return None
finally:
conn.unbind_s()
def login_required(f):
"""Decorator for protected routes."""
@wraps(f)
def decorated(*args, **kwargs):
if 'user' not in session:
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated
Deployment
Docker Configuration
# Dockerfile
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Install LDAP dependencies
RUN apt-get update && apt-get install -y \
libldap2-dev \
libsasl2-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy application
COPY . .
# Create non-root user
RUN useradd -m appuser && chown -R appuser:appuser /app
USER appuser
EXPOSE 8000
CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "4", "app:create_app()"]
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "8000:8000"
environment:
- DATABASE_URL=postgresql://inventory:secret@db:5432/inventory
- LDAP_SERVER=ldaps://ldap.yourorg.com
- SECRET_KEY=${SECRET_KEY}
depends_on:
- db
restart: unless-stopped
db:
image: postgres:15-alpine
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
- POSTGRES_DB=inventory
- POSTGRES_USER=inventory
- POSTGRES_PASSWORD=secret
restart: unless-stopped
volumes:
postgres_data:
Nginx Reverse Proxy
# /etc/nginx/sites-available/inventory
server {
listen 443 ssl http2;
server_name inventory.internal.yourorg.com;
ssl_certificate /etc/nginx/ssl/inventory.crt;
ssl_certificate_key /etc/nginx/ssl/inventory.key;
location / {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /static {
alias /opt/inventory/static;
expires 30d;
}
}
Lessons Learned
- Start with the workflow. Understand how people will use the tool before writing code.
- Keep dependencies minimal. Every library is a maintenance burden.
- Build for the 80%. Don’t add features nobody asked for.
- Integrate with existing auth. SSO/LDAP means users don’t need another password.
- Make it searchable. The best internal tools have great search.
Conclusion
Internal tools should solve problems, not create them. Pick boring technology, keep the scope tight, and iterate based on real usage.
The best internal tools I’ve built share one trait: they started as a simple script and grew organically based on actual needs. Start small, add features as needed, and resist the urge to overengineer.