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
