Anonymous A/B Voting Web

· February 7, 2026

Raspberry Pi + Flask + Tailscale Funnel

This project is a simple anonymous A/B voting website hosted on a Raspberry Pi. Users can vote once every 15 minutes, results update automatically every 5 seconds, and the site is publicly accessible even behind CGNAT using Tailscale Funnel.


Features

  • Anonymous A/B voting
  • One vote per browser every 15 minutes (using localStorage + timestamp)
  • Modern animated result bars
  • Results auto-refresh every 5 seconds
  • SQLite database
  • Secure reset endpoint
  • Works behind CGNAT via Tailscale Funnel
  • HTTPS automatically provided by Tailscale

Requirements

  • Raspberry Pi (or any Linux machine)
  • Python 3.8 or newer
  • Internet connection
  • A Tailscale account (free plan is enough)

1. Install system dependencies

sudo apt update
sudo apt install python3 python3-pip python3-venv sqlite3 -y

2. Create the project directory

mkdir vote-app
cd vote-app

3. Create and activate a virtual environment

python3 -m venv venv
source venv/bin/activate

4. Install Flask

pip install flask

5. Create app.py

Create a file called app.py with the following content:

from flask import Flask, request, jsonify, render_template_string
import sqlite3
import time

app = Flask(__name__)

RESET_KEY = "mysecret123"

VOTE_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Vote A/B</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; background: #f0f4f8; padding: 50px; }
button { font-size: 20px; padding: 15px 30px; margin: 20px; border: none; border-radius: 10px; cursor: pointer; color: white; }
#a { background: #ff6b6b; }
#b { background: #4ecdc4; }
.results-container { width: 60%; margin: 40px auto; text-align: left; }
.bar-wrapper { background: #ddd; border-radius: 20px; height: 40px; margin-bottom: 20px; overflow: hidden; }
.bar { height: 100%; color: white; padding-left: 15px; line-height: 40px; transition: width 1s; }
#a-bar { background: #ff6b6b; }
#b-bar { background: #4ecdc4; }
</style>
</head>
<body>
<h1>Which option do you prefer?</h1>

<div id="buttons">
<button onclick="vote('A')">Option A</button>
<button onclick="vote('B')">Option B</button>
</div>

<p id="msg"></p>
<div class="results-container" id="results"></div>

<script>
const VOTE_DURATION = 15 * 60 * 1000;

function checkVoted(){
    const v = localStorage.getItem('voted');
    if(v && Date.now() - parseInt(v) < VOTE_DURATION){
        document.getElementById('buttons').style.display = 'none';
        document.getElementById('msg').innerText = 'You already voted';
        return;
    }
    localStorage.removeItem('voted');
}
checkVoted();

function vote(option){
    fetch('/vote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({option})})
    .then(r=>r.json()).then(()=>{
        localStorage.setItem('voted',Date.now());
        document.getElementById('buttons').style.display='none';
        document.getElementById('msg').innerText='Thanks for voting!';
    });
}

function updateResults(){
    fetch('/results-json').then(r=>r.json()).then(data=>{
        const c=document.getElementById('results'); c.innerHTML='';
        data.forEach(i=>{
            const w=document.createElement('div'); w.className='bar-wrapper';
            const b=document.createElement('div'); b.className='bar'; b.style.width=i.percent+'%';
            b.innerText=i.option+': '+i.count+' ('+i.percent+'%)';
            w.appendChild(b); c.appendChild(w);
        });
    });
}
updateResults();
setInterval(updateResults,5000);
</script>
</body></html>"""

def init_db():
    conn = sqlite3.connect("votes.db")
    conn.execute("CREATE TABLE IF NOT EXISTS votes (option TEXT)")
    conn.close()
init_db()

@app.route("/")
def index():
    return render_template_string(VOTE_HTML)

@app.route("/vote", methods=["POST"])
def vote():
    option = request.json.get("option")
    if option not in ("A","B"):
        return jsonify({"error":"Invalid"}),400
    conn = sqlite3.connect("votes.db")
    conn.execute("INSERT INTO votes VALUES (?)",(option,))
    conn.commit()
    conn.close()
    return jsonify({"ok":True})

@app.route("/results-json")
def results():
    conn = sqlite3.connect("votes.db")
    cur = conn.cursor()
    cur.execute("SELECT option, COUNT(*) FROM votes GROUP BY option")
    rows = cur.fetchall()
    conn.close()
    total = sum(r[1] for r in rows) or 1
    return jsonify([{"option":r[0],"count":r[1],"percent":int(r[1]/total*100)} for r in rows])

@app.route("/reset")
def reset():
    if request.args.get("key") != RESET_KEY:
        return "Forbidden",403
    conn = sqlite3.connect("votes.db")
    conn.execute("DELETE FROM votes")
    conn.commit()
    conn.close()
    return "Votes reset"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5000)

6. Run the app

python app.py

7. Install and enable Tailscale Funnel

curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
tailscale funnel 5000

Twitter, Facebook