This is the website for DevFestMN, the annual Twin Cities developer conference run by GDG Twin Cities. The site is how attendees, speakers, and sponsors find and take part in the event:
- Attendees see the date, venue, schedule, and speakers, and buy tickets through Eventbrite.
- Speakers read the call for papers and submit talks.
- Sponsors learn about sponsorship and see who already supports the event.
- Organizers use the admin pages to manage speakers, sessions, and the schedule, and to track ticket sales.
Follow DevFestMN on Bluesky and Mastodon.
Lead maintainer: Stephen Fluin (Mastodon). Questions about the event go to info@devfest.mn.
npm install
npm startThen open http://localhost:4200/. The app reloads automatically when you change a source file.
- Create a branch and make your changes.
- Check them locally with
npm start(andnpm run buildfor a production build). - Open a pull request against
main. - Once it's approved and merged, the site deploys automatically.
The site is an Angular app with server-side rendering. Data such as talks, speakers, and the schedule lives in the Firebase Realtime Database, and speaker photos live in Firebase Storage.
It's served by Firebase App Hosting (backend devfestmn-2026-webapp, configured in firebase.json and
apphosting.yaml). Every merge to main triggers a new App Hosting rollout to https://devfest.mn, so there's
no manual deploy step for the website.
Database and Storage security rules (database.rules.json and storage.rules) aren't deployed by App
Hosting. To change them, sign in and deploy just the rules. The Firebase CLI is a project dependency, so run it
with npx rather than installing it globally:
npx firebase loginnpx firebase deploy --only database,storageWe keep the same codebase every year but move to a new Firebase project.
- Create the new Firebase project and an App Hosting backend connected to this repo.
- Point
.firebasercat the new project, and update thebackendIdinfirebase.json. - Update
src/environments/environment.tswith the new Firebase config, event date, CFP dates, venue, and feature flags (showSchedule,showRegister, and so on). The call for speakers shows whilecfpClosesis set and hasn't passed; set it tonullfor a new year until the CFP is announced. - Find and replace the old year and date throughout the code.
- Update the date in
scripts/og-image.shand run it to regenerate the social share image. - Carry the ticket history forward (see Ticket burndown).
- Point last year's archive at its own subdomain (see Archiving a past year).
Each past year stays online at https://<year>.devfest.mn (for example the 2025 branch and 2025-final tag).
Before the new year launches on https://devfest.mn, update the archive so search engines don't mix the two:
- In the archive's
src/environments/environment.ts, setsiteUrltohttps://<year>.devfest.mnandyearto the archived year. Canonical links,og:url, andsitemap.xmlare all built fromsiteUrl, so pages then point at the archive instead of claiming to be the current site. - Check that canonical links are absolute URLs on the subdomain, e.g.
https://2025.devfest.mn/speakers/…, not relative paths like/2025/speakers/…orhttps://devfest.mn/…. - Spot-check the deployed archive with
curl -s https://<year>.devfest.mn/sitemap.xmland the canonical tag on a speaker page.
If this is skipped, Google finds last year's speaker and session URLs on devfest.mn, where they don't exist, and reports them as duplicates in Search Console. (The current site now answers those with a 404, but the archive's links should still be fixed.)
Each archive lives on a <year> branch, cut from that year's <year>-final tag, and deploys to that year's
Firebase project. Commit fixes to the branch, push it, then deploy:
-
2025 and later (App Hosting): roll out the branch to the year's backend, e.g.
npx firebase apphosting:rollouts:create devfest-mn --git-branch 2025 --project devfestmn-2025
-
2024 (static Firebase Hosting): install with Yarn (
yarn install --frozen-lockfile), runnpx ng build, then from the branch checkout runnpx firebase deploy --only hosting --project devfestmn-2024.
Gallery photos go in src/a/images/gallery/<year>/. Before adding them, compress each photo to 50% quality
and resize it to fit within 1024x768 while keeping the aspect ratio. This command does that for every image
under the current directory and keeps a .original backup of each one (it needs ImageMagick):
find . -type f \( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" -o -iname "*.gif" -o -iname "*.webp" \) -exec sh -c '
echo "Processing: $1"
# Create backup with .original extension if it doesn'\''t exist
if [ ! -f "$1.original" ]; then
cp "$1" "$1.original"
echo "Created backup: $1.original"
fi
# Compress and resize the image
convert "$1" -quality 50 -resize "1024x768>" "$1.compressed"
# Replace original with compressed version
mv "$1.compressed" "$1"
echo "Compressed: $1"
echo "---"
' _ {} \;Then generate the small WebP previews the gallery grid uses (existing ones are skipped):
scripts/gallery-thumbnails.shThe admin burndown page (/admin/burndown) charts ticket sales over time. It reads anonymous order data
(day, time, tickets, price, and the Eventbrite affiliate code the order was placed with) from
burndown/<year> in the Realtime Database, plus promo code usage under burndown/<year>/discounts.
"Update from Eventbrite" (on the burndown and Campaigns pages) rewrites the current year only. It uses the
eventbriteToken App Hosting secret in production, or EVENTBRITE_TOKEN in a local .env file during
development.
Past years are kept in data/burndown-history.json. When the site moves to a new Firebase project, copy the
current year into that file and load it into the new project:
npx firebase database:update /burndown data/burndown-history.json --project <new-project>The admin UTM funnel page (/admin/utm-funnel) tables visits, engaged visits, ticket sales, and clicks through
to the external CFP form by UTM source,
medium, and campaign. A visit is credited to the last campaign the visitor arrived from in the past 30 days
(the same attribution passed to Eventbrite checkout), or to the linking site when there's no campaign.
The site records this itself rather than through Google Analytics. The browser sends small events to
/api/visit (src/app/visit-tracker.ts), and the server writes them to analytics/<year>/sessions
(src/visit-analytics.ts). Visits are anonymous: no IP address, user agent, or ID that links visits together
is stored.
Browsers can't write to analytics, so the server writes with its own identity, the App Hosting compute
service account. If visits don't show up after a deploy, check the server logs for permission errors and grant
that account (firebase-app-hosting-compute@<project>.iam.gserviceaccount.com) the Firebase Realtime
Database Admin role. Locally, events are dropped unless FIREBASE_DATABASE_EMULATOR_HOST points at the
Realtime Database emulator.
Most people who'd enjoy DevFestMN have never heard of it. The site has two loops for reaching them, both measured in the admin so effort and money go where tickets come from.
The admin Campaigns page (/admin/campaigns) creates one short link per place the event is promoted: a
meetup listing, a partner company, a sponsor's newsletter, a poster, an ad. Pick a preset (it prefills the
UTM source and medium and says where to put the link), give it a name, and the page shows:
https://devfest.mn/go/<slug>, which 302-redirects to the landing page tagged withutm_source,utm_medium,utm_campaignandaff=<slug>. The site remembers the campaign for 30 days (like the UTM funnel) and passes the slug to Eventbrite checkout as its affiliate code, so Eventbrite records which campaign each order came from, even on a later visit or another device.https://devfest.mn/go/<slug>.png, a printable QR code of the link, for posters, slides and flyers.- Optionally a promo code: the server creates it in Eventbrite as a coded discount (percent off, with an optional number of uses) and the link pre-applies it in checkout. This is how partners, communities and companies get a deal of their own to pass along, and how you see how many used it.
- Visits, engaged visits and on-site sales (from the UTM funnel), plus tickets and revenue Eventbrite credits to the slug, and cost per ticket once spend is entered. Click Update from Eventbrite to refresh the Eventbrite numbers.
The page's Word of mouth table measures the two links the site hands out on its own: the share prompt
shown after someone buys a ticket (utm_source=attendee) and speakers' /s/ links (utm_source=speaker).
Campaigns live in two halves under marketing/<year> in the Realtime Database: links/<slug> is public
(the server reads it to redirect) and holds only what the redirect needs; campaigns/<slug> is admin-only
(name, notes, spend, Eventbrite discount id). Deleting a campaign doesn't delete its promo code from
Eventbrite.
For paid campaigns, also set adConversions.googleAds in src/environments/environment.ts to the Google
Ads conversion id so completed orders are reported as conversions and Google Ads learns which clicks sold
tickets.
When an Eventbrite order completes on the site, a "You're in!" panel offers ready-made posts for
LinkedIn, Bluesky and X, an email, a copy-to-clipboard version for Slack and Mastodon, and the day as a
calendar entry (/calendar/devfestmn-<year>.ics). Each post links back tagged with the network, so the
funnel shows which networks attendees' posts convert on.
Confirmed speakers see a Share your talk kit on their edit page (/speaker-edit/<token>/edit): their
share card, their /s/<name> link and a post per network. The same kit appears in the admin speaker editor,
with Email their share kit, which sends it to an invited speaker. The /s/ link takes ?m=<network>
so the funnel splits speaker traffic by network too.
Setup: deploy the database rules (marketing is new). Emailing share kits uses Resend, as described under
Inviting speakers.
Speakers are added in the admin (/admin/speakers/new/edit) and only appear publicly once marked
Confirmed. Each speaker page sets its own title, description (leading with the talk), share image, and
Person structured data, and the home page lists confirmed speakers as the event's performers.
- Mark speakers Confirmed in the admin. Once at least one is, the site adds the Speakers nav link, the
home page's speakers section, and
/speakersplus every confirmed speaker in/sitemap.xml, with no deploy. Confirming a speaker publishes them, so confirm everyone together to announce the lineup at once. - Each confirmed speaker's share image is a 1200x630 card (photo, name, company, talk) rendered by the server
at
/og/speakers/<key>.png(src/speaker-card.ts). Nothing to generate: cards update when the speaker or their sessions are saved, since the image URL carries theirupdatedAt. - Give each speaker their short link,
https://devfest.mn/s/<name-slug>(shown in the speaker editor). It 302-redirects to their page withutm_source=speaker&utm_medium=social&utm_campaign=speaker_<name-slug>, so the UTM funnel shows which speakers drive visits, ticket sales, and CFP clicks. - In Google Search Console, submit
https://devfest.mn/sitemap.xml(it replacessitemap.txt, which still works) and request indexing for the new speaker pages. Each page'slastmodis when it was last saved in the admin.
On /speakers, admins can click Invite Speaker and enter a name and email. That creates an unconfirmed
speaker and emails them a private link, https://devfest.mn/speaker-edit/<token>/edit.
The token is random (not the speaker's database key, which is public), and anyone with the link can edit that
one speaker's name, bio, company, links, and photo, but can't confirm, create, or delete speakers. The
speaker editor shows the link again, with an Email it again button, for invited speakers.
Email goes through Resend from the server (src/mailer.ts, behind a small Mailer
interface so the provider can be swapped). Setup, once:
- Verify the
devfest.mndomain in Resend, so it can send asMAIL_FROMinapphosting.yaml. - Store the API key as an App Hosting secret, and let the backend read it if the command doesn't offer to:
npx firebase apphosting:secrets:set resendApiKeynpx firebase apphosting:secrets:grantaccess resendApiKey --backend devfestmn-2026-webappLocally, emails are printed to the server log instead of sent, unless .env has RESEND_API_KEY (and
optionally MAIL_FROM). MAIL_PROVIDER=console or MAIL_PROVIDER=resend forces one or the other.
The edit page signs visitors in with Firebase anonymous auth, and the database rules
(speakerInvites and speakerGrants in database.rules.json) only let a signed-in user edit a speaker after
presenting a valid token. So this needs:
- Anonymous sign-in enabled in the Firebase console (Authentication → Sign-in method → Anonymous), once per year's project.
- The database rules deployed (see How the site is built and deployed).
Speakers upload their photo through the server (POST /api/speaker-edit/<token>/photo), because the Storage
rules only let admins upload. The server checks the token and writes the file to
devfest<year>/speakers/<key>/ with its App Hosting service account, which needs permission to create objects
in the bucket. If uploads fail with a Storage 403 in the App Hosting logs, grant it:
gcloud storage buckets add-iam-policy-binding gs://devfestmn-2026.firebasestorage.app --member=serviceAccount:firebase-app-hosting-compute@devfestmn-2026.iam.gserviceaccount.com --role=roles/storage.objectCreatorLocally there are no Google credentials, so to try uploads run the server with
FIREBASE_DATABASE_EMULATOR_HOST and FIREBASE_STORAGE_EMULATOR_HOST pointing at the emulators.
To revoke a link, delete its entry under devfest<year>/speakerInvites in the Realtime Database console. Anyone
who already opened it keeps access until you also delete their entry under devfest<year>/speakerGrants.