Guides

Deployment

Production setup on the server, updating an existing deployment, backups, and troubleshooting.

Production runs on a Debian AMD64 server behind a Cloudflare Tunnel. The app is built with output: "standalone" and uses a local SQLite database.

All steps are run on the server unless noted otherwise.

1. Initial Setup

Terminal
$ node --version   # confirm Node.js is installed (see .nvmrc for target version)

$ sudo corepack enable pnpm
$ pnpm --version

# C++ toolchain for compiling better-sqlite3's native addon
$ sudo apt-get update && sudo apt-get install -y build-essential python3

2. Get the Code

Terminal
$ git clone <repo-url> /home/trickfire/dashboard
$ cd /home/trickfire/dashboard
$ pnpm install --frozen-lockfile

3. Configure Environment

Create /home/trickfire/dashboard/.env.production using .env.example as a template. Minimum required values:

Terminal
$ NEXT_PUBLIC_APP_URL=https://dashboard.trickfirerobotics.com
$ BETTER_AUTH_URL=https://dashboard.trickfirerobotics.com
$ BETTER_AUTH_SECRET=<openssl rand -hex 32>
$ VAULT_ENCRYPTION_KEY=<openssl rand -hex 32>

$ DATABASE_PATH=/home/trickfire/db/dashboard.db

# Email
$ RESEND_API_KEY=re_...
$ EMAIL_FROM=TrickFire Robotics <[email protected]m>

For Tailscale, Minecraft, and Pl3xMap env vars see the Server Integrations Setup guide.

Note

NODE_ENV=production is set in the systemd service file - do not add it to .env.production. Next.js uses it to decide which env files to load; putting it in the file it is trying to load creates a circular dependency.

Danger

Keep .env.production off git. BETTER_AUTH_SECRET lets anyone forge session tokens - rotate it immediately if it leaks (changing the value invalidates all sessions). Losing VAULT_ENCRYPTION_KEY makes every vault entry permanently unrecoverable - back it up separately.

4. Database

Terminal
$ mkdir -p /home/trickfire/db
$ pnpm exec drizzle-kit migrate
$ pnpm db:seed

The seed is idempotent - safe to run again without creating duplicates.

The approved-vendor list isn't seeded. After the first deploy, load it from the dashboard - see Updating the approved vendor list.

5. Build

Terminal
$ pnpm build

# Copy static assets into the standalone output - required after every build
$ cp -r .next/static  .next/standalone/.next/static
$ cp -r public        .next/standalone/public

Warning

If you skip the cp steps the app starts but all static assets (JS, CSS, images) return 404. This is the most common cause of "login works but the page looks broken."

The entrypoint is .next/standalone/server.js. better-sqlite3 is declared as a serverExternalPackages module, so it loads from node_modules at runtime rather than being bundled - keep node_modules present alongside the standalone output.

6. Systemd Service

Create /etc/systemd/system/trickfire-dashboard.service:

INI
[Unit]
Description=TrickFire Robotics Dashboard
After=network.target

[Service]
Type=simple
User=trickfire
WorkingDirectory=/home/trickfire/dashboard
EnvironmentFile=/home/trickfire/dashboard/.env.production
Environment=NODE_ENV=production
Environment=PORT=3000
Environment=HOSTNAME=127.0.0.1
ExecStart=/usr/bin/node /home/trickfire/dashboard/.next/standalone/server.js
TimeoutStopSec=10
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Important

Bind to 127.0.0.1, not 0.0.0.0. The Cloudflare Tunnel is the only entry point - binding to all interfaces would expose the app on the local network without TLS.

Terminal
$ sudo systemctl daemon-reload
$ sudo systemctl enable --now trickfire-dashboard
$ sudo systemctl status trickfire-dashboard
$ journalctl -u trickfire-dashboard -f

7. GitHub Actions Runner

Every push to main runs the deploy automatically via a self-hosted runner. This replaces the manual update steps for day-to-day releases.

Install the runner

The runner must run as the trickfire user.

Terminal
$ sudo -u trickfire -s
$ mkdir -p /home/trickfire/actions-runner && cd /home/trickfire/actions-runner

Go to the GitHub repo → Settings → Actions → Runners → New self-hosted runner, select Linux / AMD64, and follow the download and configure commands shown there. When prompted for labels, accept the default (self-hosted).

Install as a systemd service:

Terminal
# Still as the trickfire user
$ sudo ./svc.sh install trickfire
$ sudo ./svc.sh start
$ sudo systemctl status actions.runner.*

Allow the runner to restart services

The deploy script calls sudo systemctl restart trickfire-dashboard. Grant passwordless sudo for this and the Minecraft service:

Terminal
$ sudo visudo -f /etc/sudoers.d/trickfire-runner

Add:

plaintext
trickfire ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart trickfire-dashboard
trickfire ALL=(ALL) NOPASSWD: /usr/bin/systemctl start minecraft
trickfire ALL=(ALL) NOPASSWD: /usr/bin/systemctl stop minecraft

Push a commit to main and watch the Deploy workflow in the GitHub Actions tab to verify.

Note

Deploys are serialised - if two merges land in quick succession, the second waits for the first to finish.

8. Cloudflare Tunnel

Terminal
$ cloudflared tunnel login
$ cloudflared tunnel create trickfire-dashboard

Create /etc/cloudflared/trickfire-dashboard.yml (named after the tunnel, not the generic config.yml, so it coexists with other tunnels on the same server):

YAML
tunnel: <tunnel-uuid>
credentials-file: /home/trickfire/.cloudflared/<tunnel-uuid>.json

ingress:
    - hostname: dashboard.trickfirerobotics.com
      service: http://127.0.0.1:3000
    - service: http_status:404
Terminal
$ cloudflared tunnel route dns trickfire-dashboard dashboard.trickfirerobotics.com

cloudflared service install always creates a unit named cloudflared.service regardless of the --config flag. To get a named service that can coexist with other tunnels, write the unit file manually:

Terminal
$ sudo tee /etc/systemd/system/cloudflared-trickfire-dashboard.service << 'EOF'
$ [Unit]
$ Description=cloudflared - trickfire-dashboard
$ After=network-online.target
$ Wants=network-online.target

$ [Service]
$ TimeoutStartSec=15
$ Type=notify
$ ExecStart=/usr/bin/cloudflared --no-autoupdate --config /etc/cloudflared/trickfire-dashboard.yml tunnel run
$ Restart=on-failure
$ RestartSec=5s

$ [Install]
$ WantedBy=multi-user.target
$ EOF

$ sudo systemctl daemon-reload
$ sudo systemctl enable --now cloudflared-trickfire-dashboard

Note

The /api/sim/* export endpoints (used by the simulation CLI) are reachable through the same tunnel and are IP-rate-limited via x-forwarded-for / cf-connecting-ip headers injected by Cloudflare. They authenticate with the rotating club access code - see External Integrations.

9. Uptime Monitoring

A Cloudflare Worker (health/) pings /api/health every 5 minutes and posts a Discord alert if the dashboard is unreachable. It runs on Cloudflare's edge and is unaffected by server outages.

It deploys automatically via the Deploy Monitor GitHub Actions workflow whenever health/** changes on main. To deploy manually instead:

Terminal
$ cd health
$ pnpm dlx wrangler deploy
$ pnpm dlx wrangler secret put DISCORD_WEBHOOK_URL   # paste the webhook URL when prompted

The HTTP handler also responds to a direct request to the Worker URL, useful for triggering an instant check during testing.

To update the Discord user IDs that get pinged on failure, edit PING_IDS in health/src/index.js and redeploy.

Staging

pnpm staging rsyncs your local working tree (including uncommitted changes) to /home/trickfire/dashboard-staging on the server, builds it there, and serves it over HTTPS via Tailscale Serve on port 3001 - useful for trying out changes against real production data before merging to main.

Terminal (local machine)
$ pnpm staging

Browse to the URL printed in the terminal (your machine's Tailscale HTTPS hostname) once the build finishes. Press Ctrl+C to stop - this also tears down the Tailscale Serve config.

Every run copies /home/trickfire/db/dashboard.db into a separate db/staging.db, migrates it, and wipes the session table - so staging always reflects current prod data, but no live prod session cookie works against it and nothing you do in staging writes back to prod.

Note

Login with any real account from prod - since sessions are wiped on every run, everyone has to log in fresh.

.env.staging

Staging uses its own /home/trickfire/dashboard/.env.staging, not .env.production - this keeps it from touching live external services. Create it once, manually, based on .env.production:

  • BETTER_AUTH_SECRET - generate a new value (openssl rand -hex 32), distinct from prod's
  • VAULT_ENCRYPTION_KEY - copy prod's value verbatim. The copied database includes the vault table, which is encrypted with this key - a different key makes every entry fail to decrypt
  • RESEND_API_KEY, TAILSCALE_API_KEY, GITHUB_TOKEN, ONSHAPE_ACCESS_KEY, ONSHAPE_SECRET_KEY - leave blank. Every integration checks Boolean(process.env.X) before doing anything, so an unset key makes it report "not configured" instead of erroring - and it means staging never sends real email or calls the real Tailscale/GitHub/OnShape APIs
  • Everything else - copy as-is

Important

Keep .env.staging off git, same as .env.production.

Updating an Existing Deployment

Tip

In practice, merging to main triggers the GitHub Actions runner to do all of this automatically. These manual steps are a fallback for when the runner is unavailable.

Terminal
$ cd /home/trickfire/dashboard
$ git pull
$ pnpm install --frozen-lockfile      # recompiles native deps if versions changed
$ pnpm exec drizzle-kit migrate       # apply any new schema migrations
$ pnpm build
$ cp -r .next/static  .next/standalone/.next/static
$ cp -r public        .next/standalone/public
$ sudo systemctl restart trickfire-dashboard

Check logs immediately after restarting:

Terminal
$ journalctl -u trickfire-dashboard -f

Updating the approved vendor list

The vendor search and the order form's Approved / Out-of-scope badge read from approved_vendors, which is loaded from the finance office's supplier export (.xlsx). Nothing in git or the deploy carries this data - it lives only in the database.

  1. Get the latest supplier export from finance.
  2. In the dashboard, open Finance -> Approved vendors -> Upload export and pick the file.
  3. Review the preview: every vendor that will be added, removed, or changed (with old and new values). Nothing changes until you click Apply.

Do this once after the first deploy, then again whenever finance sends a new export. The card shows when the list was last updated and by whom. Locally, pnpm db:import-vendors [path] [--dry-run] does the same import from the command line.

Backups

All application state is the SQLite file at /home/trickfire/db/dashboard.db. A backup script and nightly cron job are already installed on the server.

Script/home/trickfire/scripts/backup-db.sh
ScheduleDaily at 02:00 (server local time)
Retention14 days
Log/home/trickfire/backups/backup.log
Terminal
$ ~/scripts/backup-db.sh   # run a manual backup at any time
$ crontab -l               # verify the cron entry

Restore from backup

Terminal
$ sudo systemctl stop trickfire-dashboard
$ cp /home/trickfire/backups/dashboard-<date>.db /home/trickfire/db/dashboard.db
$ sudo systemctl start trickfire-dashboard

Database Safety

pnpm db:reset cannot run in production. A predb:reset lifecycle hook in package.json checks NODE_ENV and exits before the reset executes. The database directory (/home/trickfire/db/) is also outside the application directory, so rm -rf ~/dashboard won't touch it.

Troubleshooting

On this page