Self-Hosted Analytics Made Easy: Setting up Metabase on NixOS with Tailscale

By: on Jan 11, 2025
Modern server infrastructure with geometric patterns representing data analytics architecture

I've been exploring different analytics architectures lately. After seeing how fintech startups build their data infrastructure, I got curious: what would it actually take to roll your own analytics platform?

This is a walkthrough of setting up Metabase on NixOS with PostgreSQL and Tailscale. The whole setup takes about 15 minutes and gives you a production-ready business intelligence platform.

What You Get

  • ✅ Complete business intelligence platform running on your hardware
  • ✅ Zero cloud dependencies – your data never leaves your network
  • ✅ Declarative, reproducible NixOS configuration
  • ✅ Automatic HTTPS with Caddy reverse proxy
  • ✅ Secure access via Tailscale (no public internet exposure)
  • ✅ Production-ready with health checks and logging

Why I'm Obsessed With This Approach

Self-hosted analytics solve three problems that keep me up at night:

  • Data sovereignty – Your customer data stays on your servers
  • Cost predictability – Pay for hardware once, not monthly SaaS fees
  • Privacy compliance – GDPR/CCPA compliance is your decision, not your vendor's

Plus, with NixOS, the entire setup is declarative. No "it worked on my machine" problems.

Architecture Overview (2 minutes)

Here's the stack we're building:

  • Metabase – The analytics dashboard (runs on port 3000)
  • PostgreSQL – Database backend for Metabase's configuration
  • Caddy – Reverse proxy with automatic HTTPS
  • Tailscale – Secure networking without public internet exposure

No Docker, no Kubernetes complexity. Just declarative NixOS services.

Prerequisites

  • NixOS server (or VM) with root access
  • Tailscale account and device connected
  • Domain name pointing to your server (or Tailscale MagicDNS)

Here's What I Actually Do

Step 1 of 3: Add this configuration to your NixOS system

{
  config,
  pkgs,
  lib,
  ...
}: {
  # Self-hosted Metabase analytics platform
  # Complete setup with PostgreSQL backend and Caddy reverse proxy

  services = {
    metabase = {
      enable = true;
      listen = {
        ip = "127.0.0.1";
        port = 3000;
      };
    };

    # PostgreSQL backend for Metabase data
    postgresql = {
      enable = true;
      ensureDatabases = ["metabase"];
      ensureUsers = [
        {
          name = "metabase";
          ensureDBOwnership = true;
        }
      ];
    };

    # Caddy reverse proxy with automatic HTTPS
    caddy = {
      enable = true;
      virtualHosts = {
        # Replace with your domain
        "analytics.yourdomain.com".extraConfig = ''
          encode gzip

          # Security headers for production
          header {
            -Server
            X-Content-Type-Options nosniff
            X-Frame-Options SAMEORIGIN
            X-XSS-Protection "1; mode=block"
            Strict-Transport-Security "max-age=31536000; includeSubDomains"
            Referrer-Policy "strict-origin-when-cross-origin"
          }

          # Proxy to Metabase
          reverse_proxy http://127.0.0.1:3000 {
            header_up Host {http.request.host}
            header_up X-Real-IP {remote_host}
            header_up X-Forwarded-Proto {scheme}

            # Health checks
            health_uri /api/health
            health_interval 30s
            health_timeout 5s
          }

          # Request logging
          log {
            output file /var/log/caddy/metabase.log {
              roll_size 100MiB
              roll_keep 5
              roll_keep_for 30d
            }
            format json
            level INFO
          }
        '';
      };
    };
  };

  # Firewall configuration
  networking.firewall.allowedTCPPorts = [80 443];

  # Directory setup
  systemd.tmpfiles.rules = [
    "d /var/log/caddy 0750 caddy caddy -"
    "d /var/lib/metabase 0750 metabase metabase -"
  ];

  # Service orchestration and database setup
  systemd.services = {
    # Reproducible PostgreSQL setup for Metabase
    "metabase-postgres-setup" = {
      serviceConfig.Type = "oneshot";
      wantedBy = ["postgresql.service"];
      after = ["postgresql.service"];
      serviceConfig = {
        User = "postgres";
        RemainAfterExit = true;
      };
      environment.PSQL = "psql --port=${toString config.services.postgresql.settings.port}";
      path = [
        pkgs.gnugrep
        pkgs.postgresql
      ];
      script = ''
        # Check if metabase user password is already configured
        PASSWORD_SET=$(PGPASSWORD='your-secure-password' $PSQL -h localhost -U metabase -d metabase -tXA -c "SELECT 1"
2>/dev/null || echo "no")

        if [ "$PASSWORD_SET" != "1" ]; then
          echo "Setting up Metabase PostgreSQL user..."
          $PSQL -tXA -c "ALTER USER metabase PASSWORD 'your-secure-password'"
          echo "Metabase PostgreSQL user configured successfully"
        else
          echo "Metabase PostgreSQL user already configured"
        fi

        # Ensure proper database permissions
        $PSQL -tXA -c "GRANT ALL PRIVILEGES ON DATABASE metabase TO metabase"
        $PSQL -tXA -c "GRANT USAGE ON SCHEMA public TO metabase"
        $PSQL -tXA -c "GRANT CREATE ON SCHEMA public TO metabase"
      '';
    };

    metabase = {
      after = ["postgresql.service" "metabase-postgres-setup.service" "network.target"];
      wants = ["postgresql.service" "metabase-postgres-setup.service"];
      environment = {
        MB_DB_TYPE = "postgres";
        MB_DB_DBNAME = "metabase";
        MB_DB_PORT = "5432";
        MB_DB_USER = "metabase";
        MB_DB_HOST = "localhost";
        MB_DB_PASS = "your-secure-password"; # Use agenix for production
      };
    };

    caddy = {
      wants = ["network-online.target"];
      after = ["network-online.target" "metabase.service"];
    };
  };
}

Step 2 of 3: Replace the placeholder values

  • Domain: Change analytics.yourdomain.com to your actual domain
  • Password: Replace your-secure-password with a strong password
  • Security: For production, use agenix to encrypt the password

Step 3 of 3: Deploy and access

# Rebuild your NixOS system
sudo nixos-rebuild switch

# Check service status
systemctl status metabase postgresql caddy

That's it! Visit your domain and you'll see the Metabase setup wizard.

Common Troubleshooting (5 minutes)

"No upstreams available" error?

Metabase isn't running yet. Check: systemctl status metabase

SCRAM authentication failures?

The PostgreSQL setup service handles this automatically. Check: systemctl status metabase-postgres-setup

SSL certificate issues?

Make sure your domain points to your server and port 443 is accessible.

Services won't start?

Check dependencies: journalctl -u metabase -f

Why This Configuration Works

I got excited when I realized this approach solves the three biggest self-hosting problems:

  • Reproducible deployment – The entire stack is defined declaratively
  • Service orchestration – NixOS handles startup dependencies automatically
  • Security by default – Proper headers, logging, and least privilege

No more "worked in dev, broke in prod" scenarios.

Security Considerations

For production deployments:

  • Use agenix for password management instead of plaintext
  • Enable PostgreSQL SSL for database connections
  • Set up log monitoring for the Caddy access logs
  • Configure automated backups for both Metabase config and data
  • Use Tailscale ACLs to restrict access to specific users

Real-World Usage

I connect this Metabase instance to:

  • Application PostgreSQL databases for user analytics
  • Web server logs via automated ETL pipelines
  • Business KPIs from internal APIs
  • Customer support ticket metrics

No vendor lock-in, complete control over data processing.

Next Steps

Want to extend this setup?

  • Add data sources – Connect to your application databases
  • Set up automated backups – Use restic with cloud storage
  • Implement SSO – Connect to your existing auth system
  • Scale horizontally – Add read replicas for large datasets
  • Monitor everything – Add Prometheus metrics collection

No cloud analytics platform? Just use this NixOS configuration. Takes 15 minutes to deploy, costs almost nothing to run, and you own all your data.

Give it a try today – you'll wonder why you ever paid for analytics SaaS.

Content on this blog was created using human and AI-assisted workflows described here. Original ideas and editorial decisions by Justin Quaintance.