Building In-House Web Applications: A Practical Guide

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:

BuildBuy
Workflow is unique to your organizationStandard business process
Integration with internal systems is criticalStandalone functionality
Data sensitivity requires full controlAcceptable vendor access
Long-term cost of SaaS exceeds developmentOne-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

  1. Start with the workflow. Understand how people will use the tool before writing code.
  2. Keep dependencies minimal. Every library is a maintenance burden.
  3. Build for the 80%. Don’t add features nobody asked for.
  4. Integrate with existing auth. SSO/LDAP means users don’t need another password.
  5. 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.

Resources