VoteHost Elections is a self-hosted election platform for organizations that need secure, private voting without relying on third-party services. Admins manage elections and voters through a web panel; voters receive a magic link by email and cast their ballot anonymously without creating an account.
Designed to run on a Raspberry Pi, mini PC, VPS, or Proxmox LXC.
Note from Cableboy1515: Hey all! This is a vibe-coded election software project I wanted to create beacuse an organization I participate in was wanting to hold electronic elections, but all platforms required paying for a service if you have over 20 or so voters. We have over 150, so I decided to see what I could create with my buddy, Claude. I tried to take careful consideration while building this platform with over 240 commits, but the reality is that I am not a coder. I'm hopeful that this is a good start and the community can analyze, offer suggestionss and help this platform grow.
I plan on running some large-scale tests with my organization soon. In addition to the audit system that's built, I will ask my organization to vote in a test election and email results that they vote for as additonal testing and verification.
Hopefully together we can make an excellent open-source project!
Source code lives at github.com/Cableboy1515/VoteHost — the repository keeps the original short name.
Copyright © 2026 Christopher Dewald. Licensed under the AGPL-3.0-or-later.
- Secret ballot — votes are recorded anonymously; no one can link a submitted ballot to a voter
- Magic link voting — voters click a link in their email, no account or password required
- Multiple question types — single choice, multiple choice (with optional seat limit), preference ranking (per-rank breakdown, no IRV), and free-text write-in
- Candidate profiles — photo avatars, bio text, and website links; voters expand details inline
- Per-voter option randomization — eliminates primacy bias with a deterministic shuffle seeded by a per-voter value
- Email invitations and reminders — configurable early reminder and a 24-hour final reminder; each voter receives at most one of each regardless of how often the cron runs
- Results announcement — one-click results email with charts sent to all voters after the election closes
- Customizable email branding — per-election subject, message body, header logo, and footer
- Image retention — uploaded logos and avatars are automatically replaced with a transparent placeholder after a configurable number of days, reducing long-term server load from old inbox links
- Admin roles — ADMIN (full access including user management, settings, and election deletion), ORGANIZER (election management only), and VIEWER (read-only access to elections and results); elections must be archived before they can be deleted, and only ADMIN can delete them
- SMTP or Resend — bring your own email provider; configured through the admin settings panel
- Election activation — one-click activation sends invites immediately; elections with a scheduled start time activate and deliver invitations automatically
- Results exports — download results as CSV, XLSX, or PDF; a full anonymised audit package (JSON) is also available for independent verification
- Activity log — every admin and organizer action is recorded and viewable per election
- Voter ballot recovery — voters who lose their magic link can request a fresh one without contacting an admin
- Admin controls — admins can close an election early or archive completed elections
- A Linux machine (Raspberry Pi 4/5, mini PC, VPS, Proxmox LXC, etc.) with at least 2 GB RAM recommended (1 GB minimum)
- Docker Engine and Docker Compose v2 (
docker compose version) - For public access over the internet, one of:
- A Cloudflare Tunnel token — requires a domain on Cloudflare DNS (free tunnel, ~$10/yr domain)
- A Tailscale auth key — free, no domain needed, gives you a stable
*.ts.netURL - Your own reverse proxy (nginx, Caddy, Traefik) if you handle TLS and routing yourself
The install script generates secrets, writes your .env, and starts the stack.
git clone https://github.com/Cableboy1515/VoteHost.git
cd VoteHost
./scripts/install.shThe wizard will ask which tunnel option you're using, prompt for your admin email and password, and then build and start the stack. Once the containers are healthy, the wizard creates your admin account automatically — no browser step required. If you skip the in-wizard admin creation, it shows the /setup URL to finish from the browser — your SETUP_TOKEN is in the generated .env file.
If you're running Proxmox VE, a single command creates an unprivileged Debian 12 LXC, installs Docker, clones VoteHost, and runs the install wizard — no manual container setup required.
Run this on your Proxmox host (as root):
bash -c "$(curl -fsSL https://raw.githubusercontent.com/Cableboy1515/VoteHost/main/scripts/proxmox.sh)"The script walks you through a short set of prompts (container ID, hostname, storage, access mode, admin account) and then handles everything else automatically. Total time is roughly 4–8 minutes depending on your connection speed.
Recommended LXC sizing
| Resource | Recommended | Minimum |
|---|---|---|
| vCPU | 2 | 1 |
| RAM | 2 GB | 1 GB |
| Disk | 15 GB | 8 GB |
Docker images for this stack total around 3 GB. Postgres and uploaded images grow over time, so 15 GB gives comfortable headroom.
Access modes
The script offers the same three access options as the standard install:
- Cloudflare Tunnel (default) — paste your tunnel token; the script configures the profile automatically. No port forwarding needed.
- Tailscale Funnel — paste a Tailscale auth key; the script adds the required
/dev/net/tundevice passthrough to the LXC config for you. - LAN only — the app binds on port 3000 of the LXC's IP address; point your own reverse proxy at it.
After install
- For Tailscale: check the container logs for your
*.ts.netURL, then updateNEXTAUTH_URLin/opt/votehost/.envand restart the app:pct exec <CTID> -- sh -c 'cd /opt/votehost && docker compose logs tailscale' # update NEXTAUTH_URL in /opt/votehost/.env, then: pct exec <CTID> -- sh -c 'cd /opt/votehost && docker compose restart app'
- To enter the container:
pct exec <CTID> -- bash - To view live logs:
pct exec <CTID> -- sh -c 'cd /opt/votehost && docker compose logs -f' - To upgrade:
pct exec <CTID> -- sh -c 'cd /opt/votehost && git pull && docker compose up -d --build'
If you prefer to configure things yourself:
1. Copy the environment template
cp .env.example .env2. Fill in the required values
Open .env and set:
| Variable | How to get it |
|---|---|
POSTGRES_PASSWORD |
Any strong password, or openssl rand -hex 16 |
DATABASE_URL |
Set to postgresql://votehost:<POSTGRES_PASSWORD>@db:5432/votehost |
NEXTAUTH_SECRET |
openssl rand -hex 32 |
NEXTAUTH_URL |
Your public URL, e.g. https://vote.example.com. Comma-separated for multiple origins: https://a.example.com,https://b.example.com |
CRON_SECRET |
openssl rand -hex 32 |
SETUP_TOKEN |
openssl rand -hex 32 — required to create the first admin account; can be removed from .env after bootstrap |
3. Build and start
docker compose build
docker compose up -dThe app container will wait for Postgres to be ready, apply the database schema automatically, then start on port 3000 (bound to 127.0.0.1 — expose it via a tunnel or reverse proxy).
4. Check the logs
docker compose logs -f appYou should see Postgres is ready, Schema applied, and Starting VoteHost Elections within about 30 seconds.
5. Create your admin account
Visit https://your-domain.com/setup. The form asks for a setup token — paste the SETUP_TOKEN value from your .env file. This prevents anyone on the internet from racing you for the admin account while the server is first starting up.
Once the admin account is created, you can optionally remove SETUP_TOKEN from .env and run docker compose restart app — it is never checked again after the first admin exists.
Tip — SSH port-forward: if you need to reach the setup page before your tunnel/DNS is live,
ssh -L 3000:localhost:3000 user@your-serverlets you browsehttp://localhost:3000/setupfrom your workstation. The forward closes when you exit the session.
The app binds only to 127.0.0.1:3000 on the host. To make it reachable from the internet, use one of the profiles below.
Requires a domain you control on Cloudflare DNS. The tunnel is free; domain registration is typically around $10/year.
- Go to the Cloudflare dashboard → Networks → Tunnels → Create a tunnel
- Choose Docker. The dashboard shows a
docker run ... --token eyJ...command — copy only the long token after--token, not the whole command. - Add to your
.env:CLOUDFLARE_TUNNEL_TOKEN=<paste only the eyJ... token> NEXTAUTH_URL=https://vote.example.com - In the Cloudflare dashboard, configure a Public Hostname for your domain. Set the origin URL to
http://app:3000—appis the docker-compose service name. Do not usehttp://localhost:3000; inside the cloudflared container,localhostrefers to cloudflared itself, not VoteHost Elections. - Start with the cloudflare profile:
docker compose --profile cloudflare up -d
No domain required. Gives you a stable https://<hostname>.<tailnet>.ts.net URL that's publicly reachable. The install wizard walks you through this interactively; the steps below are the manual equivalent.
Prerequisites — one-time tailnet setup (Tailscale admin):
-
Enable HTTPS certificates at
admin/dns→ "Enable HTTPS" -
Choose an isolation mode (see Security and threat model for the tradeoff):
Isolated (recommended): VoteHost joins as a tagged device with no peer access to the rest of your tailnet.
Open the policy file.
New Tailscale user / untouched policy file — replace the entire file with this and Save:
The
grantsentry replaces the default wildcard — your other devices still reach each other;tag:votehostis excluded.Existing tailnet (you have custom groups, ACL rules, SSH rules, tests, etc.) — add
tagOwnersandnodeAttrsas top-level keys (siblings ofgrants/acls, not nested inside the array), and replace the default wildcard grant with the member-only entry above.Legacy
acls-based tailnet? Older accounts use"acls"instead of"grants"(the two can't coexist). Substitute this for thegrantsblock:"acls": [ { "action": "accept", "src": ["autogroup:member"], "dst": ["autogroup:member:*"] } ]
tagOwnersandnodeAttrsstay at the top level either way.Non-isolated (simpler): VoteHost is a normal tailnet peer.
Easiest: in Access controls, expand the Funnel section and click Add Funnel to policy. Done.
Or edit the policy file directly:
- New Tailscale user: replace the whole file with
{ "nodeAttrs": [{ "target": ["autogroup:member"], "attr": ["funnel"] }] }. - Existing tailnet: add
nodeAttrsas a top-level key (sibling ofgrants/acls, not nested inside):
"nodeAttrs": [ { "target": ["autogroup:member"], "attr": ["funnel"] } ]
- New Tailscale user: replace the whole file with
-
Generate a Reusable auth key at
admin/settings/keysand add to.env:TS_AUTHKEY=tskey-auth-... TS_HOSTNAME=votehost # Isolated mode (recommended) — or leave TS_EXTRA_ARGS unset for the same effect TS_EXTRA_ARGS=--advertise-tags=tag:votehost # Non-isolated mode — set to empty # TS_EXTRA_ARGS= -
Start with the tailscale profile:
docker compose --profile tailscale up -d
-
Run the URL helper after the container authenticates — it detects your full
*.ts.nethostname, patchesNEXTAUTH_URLin.env, and recreates the app container:bash scripts/refresh-tailscale-url.sh
Verify isolation (isolated mode only):
docker compose exec tailscale tailscale status # self line should include tag:votehost
tailscale ping votehost # from your laptop — should fail (that's success)Switching modes later:
To go from non-isolated → isolated: apply the three-snippet policy above, edit .env to set TS_EXTRA_ARGS=--advertise-tags=tag:votehost, delete the existing untagged device at admin/machines, then docker compose --profile tailscale up -d --force-recreate tailscale.
To go from isolated → non-isolated: set TS_EXTRA_ARGS= (empty) in .env, optionally revert the ACL, delete the tagged device, and recreate the container.
Leave the tunnel profiles unused. The app is at http://127.0.0.1:3000 on the host. Point your nginx, Caddy, or Traefik config at it and handle TLS yourself.
VoteHost Elections sends email for ballot invitations, reminders, and results announcements. Configure email in the admin panel under System Settings after your first login.
Any standard SMTP provider works. Tested options:
| Provider | Host | Port | Daily limit | Notes |
|---|---|---|---|---|
| iCloud Mail | smtp.mail.me.com |
587 | ~1,000 | Requires an app-specific password, not your Apple ID password |
| Gmail | smtp.gmail.com |
587 | 500 | Requires an app password with 2FA enabled |
| Microsoft 365 / Outlook | smtp.office365.com |
587 | Varies | Use your full email as username; may require enabling Authenticated SMTP in your tenant |
| Yahoo Mail | smtp.mail.yahoo.com |
465 | Varies | Requires an App Password with 2-step verification enabled |
In the admin panel: System Settings → Email → SMTP settings. Enter the host, port, username, and password. Use the "Send test email" button to confirm delivery before your first election.
If you prefer an API-based approach:
- Create an account at resend.com and generate an API key
- In the admin panel: System Settings → Email → Resend API key
The Resend free tier allows 100 emails/day and 3,000/month — sufficient for small elections. For elections with more than 100 voters, use SMTP or a paid Resend plan.
Elections are created in DRAFT status. Once an election has at least one question, at least one voter, and an end date in the future, an Activate button appears in the election editor.
- One-click activate — clicking Activate publishes the election immediately and sends invitation emails to all voters.
- Scheduled start — set a
Starts attime when creating the election. The election stays in DRAFT until that time arrives, then auto-activates and sends invites automatically. The per-minute cron handles this without any manual action.
If voters are added to an already-active election, use Resume invitations from the Voters tab to send them their magic links.
After setup and email configuration, run through these before your first election:
- Proxy guard — open an incognito window and navigate to
/dashboard. You must be redirected to/login. - API guard —
curl -X GET https://your-domain.com/api/usersmust return403. - Secure cookie — log in, open browser DevTools → Application → Cookies → confirm
vh_sessionhasSecureandHttpOnlyset. - Email test — System Settings → Email → Send test email. Confirm it arrives.
- Cron test — confirm the reminder endpoint responds:
Should return something like:
source .env curl -s -X POST -H "Authorization: Bearer $CRON_SECRET" \ https://your-domain.com/api/reminders/run | jq
{ "elections": 0, "sent": 0, "completionsSent": 0, "draftRemindersSent": 0, "fullTurnoutNoticesSent": 0, "purged": 0, "errors": [] }
Troubleshooting — admin actions return "Forbidden": The browser's hostname doesn't match
NEXTAUTH_URLin your.env. Fix by updatingNEXTAUTH_URLto match exactly what's in your browser's address bar (scheme, host, no trailing slash), thendocker compose restart app. Alternatively, you can append the browser host as a second comma-separated value:NEXTAUTH_URL=https://original.example.com,https://actual.example.com.
Election logos and candidate photos are uploaded through the admin panel and stored in public/uploads/ on the host, mounted as a Docker volume so files survive container restarts and rebuilds.
- Avatars are resized to 256×256 px JPEG in the browser before upload; logos are scaled to max 1120 px wide.
- Uploaded images are served with a one-year
Cache-Control: immutableheader. After the first load, browsers and email-client caches (Gmail, Outlook) serve them locally. - After an election closes, the per-minute cron automatically replaces image files with a 1×1 transparent GIF once the configured retention period has passed (default: 30 days); the image-retention sweep itself is throttled to run at most once per hour. The URLs stay valid — old emails show a blank area rather than a broken-image icon. Change the retention period under System Settings → Storage & Retention.
Pull the latest code, rebuild the image, and restart. The entrypoint applies any schema changes automatically on startup.
git pull
docker compose build
docker compose up -dCheck the logs after restart to confirm the schema applied cleanly:
docker compose logs app | grep -E "Schema|Starting|error"Admins can create and download an encrypted backup of the entire database directly from System Settings → Backup & Restore, and restore from the same page. This is the simplest option for most self-hosters.
For scripted or off-site backups, use pg_dump against the database volume directly:
# One-time backup
docker compose exec db pg_dump -U votehost votehost > votehost-$(date +%Y%m%d).sql
# Restore from a backup
docker compose exec -T db psql -U votehost votehost < votehost-20250101.sqlFor automated nightly backups, add this to your host crontab (crontab -e), adjusting paths:
0 3 * * * cd /opt/votehost && docker compose exec -T db pg_dump -U votehost votehost > /backups/votehost-$(date +\%Y\%m\%d).sql
Uploaded images (logos, avatars) live in the uploads Docker volume at /var/lib/docker/volumes/votehost_uploads. Include that directory in any filesystem backup you already run.
VoteHost Elections uses a layered verification system so that any interested party — not just administrators — can confirm that the published results are accurate and complete.
Ballot receipts — when a voter submits their ballot, the server generates a unique receipt code (e.g. ABCD-EFGH-IJKL-MNOP) and returns it on the confirmation screen and by email. The code does not reveal what the voter chose; it only proves that a ballot was recorded. Anyone can enter a code at /verify/[electionId] to confirm it exists in the election ledger.
Tally hash — when an election closes, the server computes a SHA-256 hash of every vote record in canonical form and stores it with the election. This hash is displayed on the admin results page and embedded in every export (PDF footer, CSV header comment, XLSX "Verification" sheet). Because the hash is derived from the raw votes, any after-the-fact change to the database — even a single vote — would produce a different hash.
Audit export — administrators can download a full anonymised audit package (JSON) from the results page under Export → Audit export (JSON). It contains every vote record (with ballotId grouping, but no voter identity), every ballot receipt hash, the tally hash, and the algorithm description needed to recompute it independently.
| Claim | How to verify |
|---|---|
| The tally hash hasn't changed since the election closed | Recompute the hash from the audit export and compare to the published value |
| The vote counts match the raw data | Re-tally the votes array in the audit export and compare to the displayed results |
| Every ballot receipt corresponds to a real ballot | Compute each ballot's hash from the audit export and confirm it appears in ballotReceipts |
| No extra ballots were silently added | The number of unique ballotId values in votes must equal the length of ballotReceipts |
1. Get the published tally hash
On the admin results page, copy the sha256: hash from the "Tally verification" section. The same hash appears on the public verification page at /verify/[electionId].
2. Download the audit export
On the admin results page: Export → Audit export (JSON). Save it as audit.json.
3. Run the verification script
Save the following as verify.mjs in the same directory as audit.json, then run node verify.mjs:
import { createHash } from "node:crypto"
import { readFileSync } from "node:fs"
const audit = JSON.parse(readFileSync("audit.json", "utf8"))
// ── Helper: canonical sort matching the server's algorithm ──────────────────
function sortVotes(votes) {
return [...votes].sort((a, b) => {
if (a.questionId !== b.questionId) return a.questionId.localeCompare(b.questionId)
const ao = a.optionId ?? ""
const bo = b.optionId ?? ""
if (ao !== bo) return ao.localeCompare(bo)
return (a.rank ?? 0) - (b.rank ?? 0)
})
}
function sha256(obj) {
return createHash("sha256").update(JSON.stringify(obj)).digest("hex")
}
// ── 1. Recompute the tally hash ─────────────────────────────────────────────
const canonical = sortVotes(audit.votes).map(v => ({
ballotId: v.ballotId,
questionId: v.questionId,
optionId: v.optionId,
rank: v.rank,
writeInText: v.writeInText,
}))
const computed = "sha256:" + sha256(canonical)
const published = audit.tallyHash
console.log("Published hash:", published)
console.log("Computed hash: ", computed)
console.log("Hash match: ", computed === published ? "YES ✓" : "NO ✗ — results may have been altered")
// ── 2. Re-tally votes ───────────────────────────────────────────────────────
console.log("\nVote counts by question → option:")
const tally = {}
for (const v of audit.votes) {
const q = audit.questions.find(q => q.id === v.questionId)?.text ?? v.questionId
const o = audit.questions.flatMap(q => q.options).find(o => o.id === v.optionId)?.text ?? v.optionId ?? v.writeInText ?? "(write-in)"
const key = `${q} → ${o}`
tally[key] = (tally[key] ?? 0) + 1
}
for (const [k, n] of Object.entries(tally)) console.log(` ${n.toString().padStart(4)} ${k}`)
// ── 3. Verify ballot receipts ───────────────────────────────────────────────
const groups = Map.groupBy(audit.votes, v => v.ballotId)
let receiptMismatches = 0
for (const [ballotId, ballotVotes] of groups) {
const ballotCanonical = sortVotes(ballotVotes).map(v => ({
questionId: v.questionId,
optionId: v.optionId,
rank: v.rank,
writeInText: v.writeInText,
}))
const ballotHash = sha256(ballotCanonical)
if (!audit.ballotReceipts.some(r => r.ballotHash === ballotHash)) {
console.error(` No receipt found for ballotId ${ballotId}`)
receiptMismatches++
}
}
const uniqueBallots = groups.size
const receiptCount = audit.ballotReceipts.length
console.log(`\nBallot receipt check:`)
console.log(` Unique ballots in votes: ${uniqueBallots}`)
console.log(` Receipts in ledger: ${receiptCount}`)
console.log(` Counts match: ${uniqueBallots === receiptCount ? "YES ✓" : "NO ✗"}`)
console.log(` All ballots have a receipt: ${receiptMismatches === 0 ? "YES ✓" : `NO ✗ — ${receiptMismatches} missing`}`)Node.js version note:
Map.groupByrequires Node.js 21+. On older versions, replace it with:const groups = new Map() for (const v of audit.votes) { if (!groups.has(v.ballotId)) groups.set(v.ballotId, []) groups.get(v.ballotId).push(v) }
4. Interpret the results
- Hash match: YES — the vote records in the audit export are identical to what was hashed when the election closed. The results have not been altered.
- Hash match: NO — the database was modified after closing. Treat the published results as unverified.
- Ballot receipt check: YES — every recorded ballot has a corresponding receipt in the ledger, and the counts match. No ballots were silently added or removed.
- Vote counts — compare the tally printed by the script against the results shown in the admin panel. They must match exactly.
If the server itself recorded a different choice than the one a voter submitted (i.e. the server binary lied at the moment of submission), the receipt would still look valid. Closing this gap fully requires browser-side encryption, which is incompatible with ranked-choice and write-in question types. For most organizational elections — where the threat is database tampering or a rogue admin fudging results after the fact — the hash-and-receipt system described above is sufficient.
VoteHost is designed for small-organisation elections (HOAs, clubs, small nonprofits). Its security model is:
- Ballot anonymity — votes are not linked to voter identity in the database
- Voter authenticity — magic-link tokens are SHA-256 hashed; plain tokens are never stored
- Tally integrity — a SHA-256 hash of the final tally is published at election close; anyone can recompute it from the audit export (see Election verification)
- Admin 2FA — TOTP two-factor authentication is available for all roles; ADMIN and ORGANIZER users are prompted to enrol on first login (dismissible, not enforced at the gate)
VoteHost uses a server-trust model — the organisation running the server is trusted. It is not end-to-end verifiable like Helios or Belenios. If you need a cryptographically verifiable ballot, those platforms are better suited.
Tailnet isolation (Tailscale deployments) — by default the installer joins VoteHost to your tailnet as a tagged device (tag:votehost) that is excluded from all peer-to-peer tailnet ACL rules. Funnel traffic from the public internet still reaches it, but a compromise of the app cannot pivot laterally to your laptop, NAS, or other tailnet devices. This is a defense-in-depth measure: the realistic compromise path (web RCE in the app) lands the attacker in the app container, which has no Tailscale credentials or socket. The isolation closes the narrower risk of a tailscaled vulnerability or a future config change exposing that socket. You can opt out at install time; see Tailscale Funnel for both modes and how to switch between them.
For vulnerability reports, see SECURITY.md.
Requires Node.js 22+ and a local PostgreSQL database.
git clone https://github.com/Cableboy1515/VoteHost.git
cd VoteHost
npm install
cp .env.example .env
# Edit .env — set DATABASE_URL to your local Postgres, fill in NEXTAUTH_SECRET and CRON_SECRET
npm run devnpm run dev starts Prisma's development server alongside Next.js. Visit http://localhost:3000/setup to create a local admin account.
Database schema changes are applied with:
npx prisma db pushThis project does not use migration files — prisma db push is the source of truth for schema state.
VoteHost Elections is licensed under the GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later). See LICENSE for the full text.
In plain English:
- You are free to use, study, modify, and share VoteHost.
- If you modify VoteHost and run it as a service (including offering paid hosting to others), you must publish your modifications under the AGPL-3.0.
- Derivative works must also be AGPL-3.0-or-later.
"VoteHost" and "VoteHost Elections" are the names of this project. The license covers the source code; it does not grant rights to use these names to promote derivative products without permission.
{ "tagOwners": { "tag:votehost": ["autogroup:admin"] }, "nodeAttrs": [ { "target": ["tag:votehost"], "attr": ["funnel"] } ], "grants": [ { "src": ["autogroup:member"], "dst": ["autogroup:member"], "ip": ["*"] } ] }