Build a Real-Time Budget Variance Dashboard: Next.js, Rust (GraphQL), Docker, and CI/CD for Finance Teams

Before You Begin

  • Rust 1.75+ (with cargo)
  • Node.js 20 LTS and pnpm
  • Docker Desktop 4.30+
  • GitHub account with GitHub Actions runner access
  • Basic familiarity with GraphQL, Docker, and CI/CD concepts

Problem: Manual Budget Reconciliation Is Slow and Error-Prone

Finance teams often reconcile budgets using spreadsheets emailed between departments. When the CEO asks "Why is our Jazz division 12% over budget this quarter?", the answer takes days to compile. Data lives in multiple sources: procurement, payroll, expenses. Each source has different formats, and variance calculations (planned vs actual) are done manually, introducing formula errors. Additionally, deployments are manual โ€” push to production on Friday afternoons, hope nothing breaks.

Solution: A budget variance dashboard that ingests data via GraphQL mutations, calculates real-time variance using Rust's performance, serves the frontend with Next.js server components, and deploys safely via a CI/CD pipeline with blue-green strategy.

Architecture Overview

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”          โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Next.js 16 App     โ”‚  GraphQL  โ”‚  Rust Backend (Actix)   โ”‚
โ”‚  (Server Comp.)     โ”‚โ—„โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”‚  async-graphql          โ”‚
โ”‚  Client Comp. for   โ”‚          โ”‚  - BudgetVariance query  โ”‚
โ”‚  filters & charts   โ”‚          โ”‚  - IngestExpense mut.    โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜          โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
       โ”‚                                     โ”‚
       โ”‚ Docker (frontend, backend, postgres) โ”‚
       โ”‚ GitHub Actions CI/CD                 โ”‚
       โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

We use a music genre theme for categories: Jazz (marketing), Blues (R&D), Rock (operations), Classical (admin). This keeps placeholders natural and memorable.

Step 1: Rust GraphQL Backend with Financial Calculations

Create a new Rust project: cargo new budget_service --lib and add dependencies in Cargo.toml:

[dependencies]
actix-web = "4"
actix-cors = "0.7"
async-graphql = "7"
async-graphql-actix-web = "7"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sqlx = { version = "0.7", features = ["runtime-tokio", "postgres"] }
tokio = { version = "1", features = ["full"] }
chrono = "0.4"

Define the GraphQL schema in src/graphql/mod.rs. We'll have a BudgetVariance object and a mutation to ingest expenses.

use async_graphql::*;

#[derive(SimpleObject)]
pub struct BudgetVariance {
    pub category: String, // e.g., "Jazz"
    pub planned_amount: f64,
    pub actual_amount: f64,
    pub variance_percentage: f64,
}

#[derive(InputObject)]
pub struct ExpenseInput {
    pub category: String,
    pub amount: f64,
    pub description: String,
}

pub struct QueryRoot;

#[Object]
impl QueryRoot {
    async fn budget_variances(&self, ctx: &Context<'_>) -> Vec<BudgetVariance> {
        // Fetch from DB, calculate variance
        // For brevity, hardcoded example
        vec![
            BudgetVariance {
                category: "Jazz".into(),
                planned_amount: 100000.0,
                actual_amount: 112000.0,
                variance_percentage: ((112000.0 - 100000.0) / 100000.0) * 100.0,
            },
            BudgetVariance {
                category: "Blues".into(),
                planned_amount: 50000.0,
                actual_amount: 48000.0,
                variance_percentage: -4.0,
            },
        ]
    }
}

pub struct MutationRoot;

#[Object]
impl MutationRoot {
    async fn ingest_expense(&self, ctx: &Context<'_>, input: ExpenseInput) -> Result<bool> {
        // Insert into DB โ€“ actual implementation would use sqlx
        Ok(true)
    }
}

Why this function exists: The budgetvariances query is the core of the dashboard. It returns pre-calculated variance percentages so the frontend doesn't have to compute them repeatedly, reducing client-side logic and ensuring consistency. The mutation ingestexpense allows finance apps or internal tools to push expense data in real time.

Why async-graphql? It is the most mature GraphQL library for Rust, supports async resolvers out of the box, and integrates easily with actix-web. Alternatives like Juniper exist but require more boilerplate for complex queries.

Wire up the server in src/main.rs:

use actix_web::{web, App, HttpServer, HttpResponse};
use async_graphql::*;
use async_graphql_actix_web::{GraphQLRequest, GraphQLResponse};
use std::sync::Arc;

mod graphql;

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let schema = Schema::build(graphql::QueryRoot, EmptyMutation, EmptySubscription)
        .data(Arc::new(/* DB pool */))
        .finish();

    HttpServer::new(move || {
        App::new()
            .service(web::resource("/graphql").route(web::post().to(graphql_handler)))
            .route("/health", web::get().to(HttpResponse::Ok))
    })
    .bind("0.0.0.0:8000")?
    .run()
    .await
}

async fn graphql_handler(schema: web::Data<Schema<graphql::QueryRoot, EmptyMutation, EmptySubscription>>, req: GraphQLRequest) -> GraphQLResponse {
    schema.execute(req.into_inner()).await.into()
}

Common pitfall: Fire and forget DB writes. Many developers on StackOverflow ask why mutations return success even when DB fails. Always validate and return proper errors using Result and async-graphql's Error type.

Step 2: Next.js Frontend with Server Components

Initialize a Next.js 16 app with pnpm:

pnpm create next-app budget-dashboard --typescript --app --eslint --import-alias "@/*"
cd budget-dashboard

Create a GraphQL client utility (lib/graphql.ts). We'll use graphql-request for simplicity:

import { GraphQLClient } from 'graphql-request';

export const client = new GraphQLClient(
  process.env.NEXT_PUBLIC_GRAPHQL_URL || 'http://localhost:8000/graphql'
);

Create a server component app/page.tsx that fetches variance data:

import { client } from '@/lib/graphql';
import { gql } from 'graphql-request';
import { JazzVarianceChart } from './JazzVarianceChart'; // client component

const GET_VARIANCES = gql`
  query GetVariances {
    budgetVariances {
      category
      plannedAmount
      actualAmount
      variancePercentage
    }
  }
`;

// Notice: 'async component' โ€“ server component by default
const HomePage = async () => {
  const data = await client.request(GET_VARIANCES);
  
  return (
    <main>
      <h1>Budget Variance Dashboard (Q3)</h1>
      <JazzVarianceChart variances={data.budgetVariances} />
    </main>
  );
};

export default HomePage;

Why server component? This data fetching happens on the server, reducing client JS bundle and improving SEO. The chart component (JazzVarianceChart) needs interactivity (hover, filters), so it's a client component. We pass data as props โ€” no client-side data fetching for the initial load.

The chart component uses useState for a date range filter and useOptimistic for instant UI updates when filtering:

'use client';
import { useOptimistic, useState } from 'react';
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer } from 'recharts';

interface Variance {
  category: string;
  plannedAmount: number;
  actualAmount: number;
  variancePercentage: number;
}

export const JazzVarianceChart = ({ variances: initial }: { variances: Variance[] }) => {
  const [datePreset, setDatePreset] = useState<'current' | 'previous'>('current');
  const [optimisticVars, setOptimisticVars] = useOptimistic(
    initial,
    (current, newVars: Variance[]) => newVars
  );

  const handleFilterChange = async (preset: 'current' | 'previous') => {
    setDatePreset(preset);
    setOptimisticVars(/* optimistic placeholder */);
    // Then fetch new data via server action or API route
  };

  return (
    <div>
      <select value={datePreset} onChange={(e) => handleFilterChange(e.target.value as any)}>
        <option value="current">Current Quarter</option>
        <option value="previous">Previous Quarter</option>
      </select>
      <ResponsiveContainer width="100%" height={400}>
        <BarChart data={optimisticVars}>
          <XAxis dataKey="category" />
          <YAxis />
          <Tooltip />
          <Bar dataKey="variancePercentage" fill="#8884d8" />
        </BarChart>
      </ResponsiveContainer>
    </div>
  );
};

Why useOptimistic? When the user changes the date preset, the UI should feel instant. Optimistic updates immediately show a placeholder (e.g., previous data with a fade) while the new data loads, preventing jarring blank states. Next.js 16 server actions can handle the actual mutation.

Sponsored Deal

Step 3: Docker Multi-Stage Builds and Docker Compose

We need to containerize both services. Use multi-stage builds for Rust to keep images small (musl target).

Dockerfile (backend):

# Stage 1: Build
FROM rust:1.75-slim AS builder
WORKDIR /app
COPY Cargo.toml Cargo.lock ./
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release 2>/dev/null || true  # cache dependencies
COPY src ./src
RUN cargo build --release

# Stage 2: Runtime
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/budget_service /usr/local/bin/
EXPOSE 8000
CMD ["budget_service"]

Dockerfile (frontend):

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile

FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build

FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "server.js"]

Docker Compose (docker-compose.yml):

version: '3.8'
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: budget
      POSTGRES_USER: finance
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  backend:
    build: ./backend
    ports:
      - "8000:8000"
    environment:
      DATABASE_URL: postgres://finance:secret@postgres:5432/budget
    depends_on:
      - postgres

  frontend:
    build: ./frontend
    ports:
      - "3000:3000"
    environment:
      NEXT_PUBLIC_GRAPHQL_URL: http://backend:8000/graphql
    depends_on:
      - backend

volumes:
  pgdata:

Run locally: docker compose up --build. This replicates the production environment on your machine.

Why multi-stage? The Rust binary is reduced from ~1.5GB to ~50MB. The Next.js output uses standalone mode (enable in next.config.js with output: 'standalone') to minimize image size. Engineering blogs from companies like DoorDash and Lyft emphasize multi-stage builds for reducing cold starts and storage costs.

Step 4: CI/CD Pipeline with Blue-Green Deployment

We'll use GitHub Actions to build images, run tests, and deploy to a staging environment using Docker Compose with blue-green pattern.

Create .github/workflows/deploy.yml:

name: Deploy Budget Dashboard
on:
  push:
    branches: [main]

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  test-and-build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Rust
        uses: actions-rust-lang/setup-rust-toolchain@v1
      - name: Build Rust
        run: cargo build --release --manifest-path backend/Cargo.toml
      - name: Run Rust tests
        run: cargo test --manifest-path backend/Cargo.toml
      - name: Build Next.js
        run: |
          cd frontend
          pnpm install
          pnpm build
      - name: Run frontend tests
        run: cd frontend && pnpm test
      - name: Login to GitHub Container Registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      - name: Build and push backend image
        uses: docker/build-push-action@v5
        with:
          context: ./backend
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/backend:${{ github.sha }},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/backend:latest
      - name: Build and push frontend image
        uses: docker/build-push-action@v5
        with:
          context: ./frontend
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/frontend:${{ github.sha }},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}/frontend:latest

  deploy-blue-green:
    needs: test-and-build
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - name: Deploy to Blue environment
        run: |
          # Assume production server with SSH access
          # Pull new images, deploy to blue, run health checks, switch traffic
          echo "Deploying to blue..."
          # Actual implementation would use ansible or scp
          ssh -o StrictHostKeyChecking=no user@server "cd /opt/budget && docker compose -f docker-compose.blue.yml pull && docker compose -f docker-compose.blue.yml up -d"
      - name: Health Check (blue)
        run: |
          for i in 1..10; do
            curl -s http://blue.server.com/api/health && break
            sleep 5
          done
      - name: Switch traffic to blue
        run: |
          # Update reverse proxy (nginx) to point to blue containers
          ssh user@server "sudo systemctl reload nginx"
      - name: Teardown green environment
        run: |
          ssh user@server "docker compose -f docker-compose.green.yml down"

Why blue-green? Rolling updates with a single service could cause downtime if the DB schema is incompatible. Blue-green keeps two full stacks identical; after health checks pass on the new stack, traffic switches immediately. GitHub Actions approval gates can be added before the switch for manual validation.

Common issue: Health checks failing due to DB migrations not running. Use a startup script that runs migrations before the backend starts. With Docker Compose, add an init container or use entrypoint to run sqlx migrate run.

Step 5: Production Hardening

  • Secrets: Never hardcode passwords. Use GitHub Secrets for DB credentials and pass them as --build-arg or environment variables at deploy time.
  • Logging: Rust backend should output structured JSON logs (use tracing crate). Next.js frontend can use pino.
  • Rate limiting: Protect GraphQL endpoints from abusive queries. Use actix-governor or a reverse proxy like Nginx.
  • Error boundaries in Next.js: Wrap client components to catch rendering errors gracefully.

Common Issues and Solutions

| Issue | Cause | Solution | |---|---|---| | params is Promise in Next.js API routes | Version 16 change | Always await params before accessing fields | | Rust binary fails to start in Docker | Missing libssl or ca-certificates | Use debian:bookworm-slim and install ca-certificates | | GraphQL query returns no data in production | CORS misconfigured | Add actix-cors middleware to Rust backend | | Docker build times long | No layer caching | Use docker/build-push-action with cache-from/to, and separate dependency build step | | Blue-green switch results in 502 errors | Health check passes but traffic still hits old containers | Ensure DNS/proxy TTL is low; use docker compose down after successful switch |

Final Thoughts

Finance teams don't need another PDF report. They need a live system they can query on Slack, a dashboard that updates in real time, and a deployment process that doesn't cause Friday-night fires. This stack โ€” Rust for performance, Next.js for modern React patterns, Docker for consistency, and a CI/CD pipeline with blue-green โ€” gives them exactly that. The music genre naming makes it easier to talk about categories without confusing stakeholders.

Extend this by adding GraphQL subscriptions for live updates, integrating with QuickBooks or Xero APIs, or adding a Rust-based data pipeline that ingests CSV exports automatically.