Deploying Hi.Events

Hi.Events offers flexible deployment options for both cloud and self-hosted environments. Whether you’re looking for a quick setup or a fully customized configuration, this guide will help you get up and running.

For most users, our recommended approach is using Docker, which simplifies deployment and ensures consistency across different environments.

Overview

Hi.Events consists of two main components:

  • Frontend: A Node.js React application that handles the user interface
  • Backend: A Laravel PHP application that powers the API and business logic

You can deploy these components either:

  • Together using our all-in-one Docker image (simplest approach)
  • Separately for better scalability and control (recommended for production)

Deployment Options

One-Click Cloud Deployment

The fastest way to deploy Hi.Events is through our verified cloud partners:

Deploy on DigitalOcean

Deploy to Render

Deploy on Railway

Deploy on Zeabur

Deploy on Elestio

Production Considerations

While one-click deployments are convenient for getting started, production environments require careful configuration of:

  • Server resources based on expected traffic
  • Database size and performance
  • Security settings and environment variables
  • Proper file storage configuration

Environment Variables

To configure your Hi.Events deployment properly, you’ll need to set up several environment variables. These control everything from database connections to email delivery settings.

The bundled docker-compose.yml passes a fixed list

The all-in-one docker/all-in-one/docker-compose.yml doesn’t pass your whole .env file to the container. It lists each variable in its environment: block. If a variable on this page isn’t in that block (for example POSTMARK_TOKEN, POSTMARK_WEBHOOK_SECRET or API_DOCS_ENABLED), add a line for it there as well as in .env, or the container never sees it.

Frontend Variables

Variable NameDescriptionExample
VITE_FRONTEND_URLFrontend URLhttps://your-app.com
VITE_API_URL_CLIENTAPI URL for use in the browserhttps://your-app.com/api
VITE_API_URL_SERVERAPI URL for use on serverThis is used for server-side rendering. In the All-in-one image this is fixed to http://localhost:80/api internally and cannot be overridden. If you’re hosting frontend and backend separately, this value would usually be the same as VITE_API_URL_CLIENT.
VITE_STRIPE_PUBLISHABLE_KEYStripe public keypk_test_51...
VITE_APP_NAMEApplication name shown in the browser title and throughout the frontend UI. Emails use the backend APP_NAME variableDefault: Hi.Events
NODE_PORTPort the frontend server listens on. Only relevant if you’re running the frontend image behind your own proxyDefault: 5678

Frontend Branding (Optional)

These are all optional — leave them unset to use the Hi.Events defaults.

Variable NameDescriptionExample
VITE_APP_LOGO_LIGHTLogo used on light backgroundshttps://your-app.com/logo-light.svg
VITE_APP_LOGO_DARKLogo used on dark backgroundshttps://your-app.com/logo-dark.svg
VITE_APP_FAVICONFavicon URLDefault: /favicon.svg
VITE_APP_PRIMARY_COLORPrimary brand colourDefault: #40296C
VITE_APP_SECONDARY_COLORSecondary brand colourDefault: #3d0b44
VITE_TOS_URLTerms of Service URL. Terms links are hidden at checkout when unsethttps://your-app.com/terms
VITE_PRIVACY_URLPrivacy Policy URLhttps://your-app.com/privacy
VITE_HIDE_ABOUT_LINKHides the “About” link in the global menu when settrue
VITE_PLATFORM_SUPPORT_EMAILSupport email surfaced in the UI[email protected]

Leave these unset to keep the default behaviour: no consent banner, and organizer tracking pixels load as soon as an event page opens.

Variable NameDescriptionExample
VITE_COOKIE_CONSENT_ENABLEDShows a site-wide cookie banner (Essential / Analytics / Advertising). Organizer tracking pixels and Google Consent Mode follow the visitor’s choicetrue
VITE_COOKIE_CONSENT_DOMAINShares the consent cookie across sub-domains. Only applied when it matches the host.your-app.com
VITE_COOKIE_CONSENT_TEXTOverrides the banner textWe use cookies to improve your experience.
VITE_GOOGLE_ADS_CONVERSION_IDGoogle Ads tag, loaded on every server-rendered page. With VITE_COOKIE_CONSENT_ENABLED=true it runs under Google Consent ModeAW-123456789
VITE_GOOGLE_ADS_CONVERSION_LABELSMaps app events to Google Ads conversion labelssignup_completed:AbCdEfGh
VITE_FATHOM_SITE_IDFathom Analytics site ID (cookieless, not gated by the banner)ABCDEFGH

Backend Variables

Mail Configuration

You can use email providers like Postmark, SendGrid, or AWS SES.

Variable NameDescriptionExample
MAIL_MAILERMail driversmtp
MAIL_HOSTMail server hostsmtp.mailtrap.io
MAIL_PORTMail server port2525
MAIL_USERNAMEMail server usernameyour-username
MAIL_PASSWORDMail server passwordyour-password
MAIL_FROM_ADDRESSMail from address[email protected]
MAIL_FROM_NAMEMail from nameYour App Name
MAIL_AUTO_TLSAutomatically negotiate TLS with the mail server. Set to false for local relays without TLSDefault: true

Encryption follows the port: port 465 uses TLS from the start, and any other port upgrades with STARTTLS when the server offers it. MAIL_ENCRYPTION, which the example .env files still set, is ignored. | MAIL_VERIFY_PEER | Verify the mail server’s TLS certificate. Set to false for self-signed certificates on local relays | Default: true |

API-based mail providers

The MAIL_HOST / MAIL_PORT / MAIL_USERNAME / MAIL_PASSWORD variables above only apply when MAIL_MAILER=smtp. If you’d rather use a provider’s API, set MAIL_MAILER to the provider name and supply its credentials instead:

ProviderMAIL_MAILERRequired variables
PostmarkpostmarkPOSTMARK_TOKEN
AWS SESsesAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION

Note

AWS SES reuses the same AWS_* credentials as S3 file storage. If you use different accounts for storage and email, use SMTP credentials for SES instead. For Mailgun, use its SMTP credentials with MAIL_MAILER=smtp: the Mailgun API driver isn’t installed.

For more details on configuring mail settings in Laravel, refer to the Laravel Mail Documentation.

Outgoing mail has to work for more than tickets: invitations, password resets and email changes all go by email. In SaaS mode, new users also have to confirm their email address before they can publish events, send messages, edit email templates, invite team members or create API keys. With SaaS mode off, accounts count as confirmed when they sign up.

Email delivery tracking (Optional)

Hi.Events records every email it sends. With Postmark, it can also record what happened to each one: delivered, bounced, marked as spam, opened or clicked. Organizers then see a Delivered, Bounced or Marked as spam badge next to each recipient in the recipient list of a sent message on the event’s Messages page, and superadmins get sent, delivered, bounce, complaint, open and click figures for each type of email on the Email Delivery page of the admin area. Without tracking, every recipient shows as Sent.

Postmark is the only provider supported for tracking. To set it up:

  1. Send mail through Postmark’s API: MAIL_MAILER=postmark with POSTMARK_TOKEN (see the table above). Events are matched to emails by Postmark’s message ID, which the API mailer records
  2. Choose a long random secret and set it as POSTMARK_WEBHOOK_SECRET on the backend
  3. In Postmark, add a webhook to your message stream with the URL https://your-app.com/api/public/webhooks/email-delivery/postmark (without /api on the separate backend image or a manual install, the same as the Stripe webhook)
  4. Turn on HTTP basic authentication for the webhook. The username can be anything; the password is your POSTMARK_WEBHOOK_SECRET
  5. Subscribe it to Delivery, Bounce and Spam Complaint, plus Open and Click if you want those figures. Open and click tracking also have to be switched on for the stream in Postmark
Variable NameDescriptionExample
POSTMARK_WEBHOOK_SECRETPassword Postmark sends with each delivery webhook. While it’s unset, the webhook URL returns 404a_long_random_string

Stripe Configuration

For more information on obtaining Stripe API keys, visit the Stripe API Keys Documentation.

Variable NameDescriptionExample
STRIPE_PUBLIC_KEYStripe public keypk_test_51...
STRIPE_SECRET_KEYStripe secret keysk_test_51...
STRIPE_WEBHOOK_SECRETStripe webhook secretwhsec_...
Setting up the Stripe webhook

For Stripe to work correctly, you need to set up the webhook in your Stripe dashboard:

  1. Go to https://dashboard.stripe.com/webhooks
  2. Click “Add endpoint”
  3. Set the webhook URL to your backend’s Stripe webhook endpoint (see the note below): https://your-app.com/api/public/webhooks/stripe
  4. You should listen for the following events:
  • payment_intent.succeeded
  • payment_intent.payment_failed
  • charge.succeeded
  • charge.updated
  • charge.refunded
  • refund.created
  • refund.updated
  • account.updated
  • payout.paid
  • payout.updated
  • terminal.reader.action_failed (only needed for box office card readers)

The /api prefix is not part of the route

The Laravel route is /public/webhooks/stripe. The /api segment only exists in the all-in-one image, whose bundled nginx strips it before passing the request to the backend.

If you’re running the separate backend image or a manual install, the correct URL is https://your-backend-host/public/webhooks/stripe — unless you’ve put a proxy in front of it that strips /api the same way.

General Configuration

Variable NameDescriptionExample
APP_KEYApplication keybase64:...
APP_NAMEApplication name used in outgoing emailsDefault: Hi.Events
APP_URLThe backend’s own public URL. Used for Artisan-generated URLs and, when using local disk storage, to build the public /storage URLhttps://your-app.com
APP_FRONTEND_URLFrontend URLhttps://your-app.com
APP_CDN_URLBase URL that uploaded files are served from. See the note below — this is effectively required for local disk storagehttps://your-app.com/storage
APP_ENVApplication environmentDefault: production
APP_DEBUGShow detailed error messages and stack traces. Must be false in productionDefault: false
APP_DISABLE_REGISTRATIONDisable registrationDisables people from registering new accounts. Suggested for non-SaaS deployments.
APP_ENFORCE_EMAIL_CONFIRMATION_DURING_REGISTRATIONSaaS mode only. Require new users to confirm their email address before they can use the appDefault: false
APP_PLATFORM_SUPPORT_EMAILSupport address shown in user-facing emails. Change this — the default is a placeholderDefault: [email protected]
APP_EMAIL_LOGO_URLLogo displayed at the top of outgoing emailshttps://your-app.com/logo.png
APP_EMAIL_LOGO_LINK_URLWhere the email logo links toDefaults to APP_FRONTEND_URL
APP_EMAIL_FOOTER_TEXTCustom footer text appended to outgoing emails© Your Company
APP_API_RATE_LIMIT_PER_MINUTEAPI requests allowed per minute. Signed-in users share one limit across the web app and all of their API keys; other requests are limited per IP addressDefault: 180
APP_PUBLIC_ORDER_RATE_LIMIT_PER_MINUTEOrders a single IP address can start per minute at public checkoutDefault: 60
APP_PUBLIC_PROMO_CODE_RATE_LIMIT_PER_MINUTEPromo code checks a single IP address can make per minute at public checkoutDefault: 10
APP_PUBLIC_PAYMENT_INTENT_RATE_LIMIT_PER_HOURCard payment attempts a single IP address can start per hour at public checkout, with a sixth of that allowed in any one minuteDefault: 120
APP_REGISTER_RATE_LIMIT_PER_HOURSign-ups allowed per hour from a single IP address, with three times that per day. Each email address is also limited to 5 attempts an hourDefault: 10
APP_ALLOWED_INTERNAL_WEBHOOK_HOSTSComma-separated hosts that outgoing webhooks may target on private/internal networks. Empty by default, which blocks internal hostsinternal.your-app.com
FILESYSTEM_PUBLIC_DISKFilesystem diskDefault: s3-public. public if you’re using local disk storage
FILESYSTEM_PRIVATE_DISKFilesystem diskDefault: s3-private. local if you’re using local disk storage
JWT_SECRETJWT secret key. A plain random string — no base64: prefixYy2f... (see below)
LOG_CHANNELLog channel. See Error Reporting and Logsstderr
CORS_ALLOWED_ORIGINSA comma-separated list of origins allowed to make CORS requests. Leave it empty to allow only APP_FRONTEND_URL (see below)https://your-app.com,https://another-origin.com
API_DOCS_ENABLEDServes the full OpenAPI documentation, including endpoints API keys can’t call, at the backend’s /docs/api path (https://your-app.com/api/docs/api on the all-in-one image). Not needed for the in-app API reference. See Using the APIDefault: false

How CORS_ALLOWED_ORIGINS falls back

When CORS_ALLOWED_ORIGINS is empty, or contains *, Hi.Events allows the origin of APP_FRONTEND_URL instead, along with its www. (or non-www.) counterpart. An https:// frontend URL allows only https; an http:// URL, or one with no scheme, allows both http and https.

* therefore no longer allows every origin. All origins are only allowed when APP_FRONTEND_URL is unset or can’t be parsed. To allow other origins as well, list every origin explicitly, including the frontend’s own, separated by commas.

Generate the APP_KEY using:

echo "base64:$(openssl rand -base64 32)"

Keep APP_KEY the same once users have signed up. It encrypts each user’s two-factor authentication secret, so changing it stops every enrolled user’s authenticator codes from working (recovery codes still work, and so does the reset command). It also encrypts each account’s single sign-on client secret, and changing it breaks invitation and email confirmation links that haven’t been used yet.

Generate the JWT_SECRET using — note there is no base64: prefix here, unlike APP_KEY:

openssl rand -base64 32

APP_CDN_URL and local disk storage

APP_CDN_URL isn’t only for CDNs — it’s the base URL prepended to every uploaded file path (event cover images, organizer logos, and so on).

If you’re using local disk storage (FILESYSTEM_PUBLIC_DISK=public), set this to your app’s public storage path, for example APP_CDN_URL=https://your-app.com/storage. If it’s left unset, Hi.Events falls back to the public disk’s URL, which is built from APP_URL — so APP_URL must be correct or uploaded images will render as broken links.

Reverse Proxies and Client IPs (Optional)

Rate limits are keyed by the visitor’s IP address, so the backend needs to know which proxies it can trust to report it.

Variable NameDescriptionExample
APP_TRUSTED_PROXIESComma-separated proxies (IPs or CIDR ranges) allowed to set X-Forwarded-For. cloudflare expands to Cloudflare’s published IP rangesDefault: *. e.g. 127.0.0.1,10.0.0.0/8,cloudflare
APP_SSR_SHARED_SECRETA random value set to the same thing on the backend and the frontend server. Lets server-rendered pages pass on each visitor’s IP when the frontend server can’t be listed in APP_TRUSTED_PROXIESa_long_random_string

The default, *, trusts whichever proxy connects to the backend and reads the client IP from its X-Forwarded-For header. That works with no proxy or a single proxy, but clients that can reach the backend directly can spoof their IP.

Behind two or more proxies (for example Cloudflare in front of Traefik, Caddy or nginx), the default treats the outer proxy as the client, so many visitors share one rate limit. In either case, list every proxy between the visitor and the backend instead, including the frontend server if it calls the API from another host.

APP_SSR_SHARED_SECRET is server-side only

The frontend reads APP_SSR_SHARED_SECRET in its server-rendering process, so it has no VITE_ prefix and is never sent to the browser. In the all-in-one image, both halves read the same variable, so set it once.

Sign-up and Checkout Protection (Optional)

Public sign-up and checkout are rate limited per IP address out of the box (see APP_REGISTER_RATE_LIMIT_PER_HOUR and the other limits in General Configuration). If your install is open for anyone to register, you can also put a Cloudflare Turnstile check on the sign-up form:

Variable NameWhereDescriptionExample
TURNSTILE_SECRET_KEYBackendTurnstile secret key. Unset turns the check off0x4AAAAAAA...
VITE_TURNSTILE_SITE_KEYFrontendTurnstile site key for the widget on the sign-up form0x4AAAAAAA...

Set both keys, or neither

The backend rejects any sign-up without a valid Turnstile token once TURNSTILE_SECRET_KEY is set. If the frontend doesn’t have VITE_TURNSTILE_SITE_KEY, the widget never appears and nobody can register. Turnstile only covers email and password sign-up; people signing up through single sign-on don’t see it. If the backend can’t reach Cloudflare to check a token, it logs a warning and lets the sign-up through.

A few protections need no configuration:

  • Only live events take public orders. Events that aren’t live (drafts, for example) only take orders from account members who can edit the event, for testing, and events held for review take none
  • After 5 failed card payments on the same order, Hi.Events cancels the Stripe payment intent, so the order can’t be used to test stolen cards. This relies on the payment_intent.payment_failed Stripe webhook and the cache, so use the redis cache driver if you run more than one backend instance

Error Reporting and Logs (Optional)

Hi.Events can report errors — and optionally forward log records — to Sentry from both the backend and the frontend’s server-side rendering process. Leave the DSNs unset to disable it entirely.

Backend:

Variable NameDescriptionExample
SENTRY_DSNSentry DSN for the backend. Unset disables Sentryhttps://[email protected]/...
SENTRY_ENVIRONMENTEnvironment name reported with each event. Set this per deployment so staging and production stay separateproduction
SENTRY_ENABLE_LOGSSend log records to Sentry Logs, not just exceptionsDefault: false
SENTRY_LOG_LEVELMinimum level forwarded when logs are enabledDefault: info
SENTRY_RELEASERelease identifier, so errors can be tied to a deployv2.3.0
SENTRY_TRACES_SAMPLE_RATEFraction of requests traced for performance monitoring. Unset or 0 disables tracingDefault: unset
LOG_STACKComma-separated channels the stack log channel writes to. Add sentry_logs to forward logs to SentryDefault: single. e.g. stderr,sentry_logs

Frontend (the SSR server — these are read by frontend/instrument.mjs when the server starts, not by the browser, so they have no VITE_ prefix):

Variable NameDescriptionExample
SENTRY_SSR_DSNSentry DSN for the SSR server. Unset disables Sentry therehttps://[email protected]/...
SENTRY_ENVIRONMENTEnvironment name. Falls back to NODE_ENV with a warningproduction
SENTRY_ENABLE_LOGSForward server console output to Sentry LogsDefault: false
SENTRY_LOG_LEVELMinimum level forwarded when logs are enabledDefault: info
SENTRY_RELEASERelease identifierv2.3.0
SENTRY_TRACES_SAMPLE_RATEFraction of requests traced. 0 disables tracingDefault: 0

Turning on Sentry Logs

Setting SENTRY_ENABLE_LOGS=true on the backend is not enough on its own — the sentry_logs channel also has to be in the stack that receives your logs. Set LOG_CHANNEL=stack and LOG_STACK=stderr,sentry_logs.

The SSR server has no such stack: SENTRY_ENABLE_LOGS=true there captures console output directly.

The SSR integration is configured to send as little as possible — user info, cookies, headers, request bodies, and query strings are all excluded.

Address Autocomplete (Optional)

Hi.Events v2 supports venue address autocomplete powered by Google Places. This is optional — without it, organizers can still enter venue addresses manually.

Variable NameDescriptionExample
GEO_PROVIDERGeocoding provider for address autocompletegoogle
GOOGLE_MAPS_API_KEYGoogle Maps API key with the Places API enabledAIza...

Single Sign-On (Optional)

Lets people sign in with Google, Microsoft or your own OpenID Connect provider. Each provider is turned on by setting its client ID and secret. These instance-wide providers are part of the open-source core and need no licence key. See Setting Up Single Sign-On for the redirect URI, provider notes and recovery.

Variable NameDescriptionExample
SSO_GOOGLE_CLIENT_IDGoogle OAuth client ID1234-abc.apps.googleusercontent.com
SSO_GOOGLE_CLIENT_SECRETGoogle OAuth client secretGOCSPX-...
SSO_MICROSOFT_CLIENT_IDMicrosoft Entra app registration (application) ID00000000-0000-...
SSO_MICROSOFT_CLIENT_SECRETMicrosoft Entra client secretabc~...
SSO_OIDC_ISSUERIssuer URL of any other OpenID Connect provider, such as Okta, Keycloak, Authentik or Zitadelhttps://sso.your-company.com/realms/main
SSO_OIDC_CLIENT_IDClient ID for that providerhi-events
SSO_OIDC_CLIENT_SECRETClient secret for that provider...
SSO_OIDC_NAMEButton label for that providerDefault: SSO
SSO_OIDC_LOGO_URLLogo shown on that provider’s buttonhttps://your-company.com/logo.svg
SSO_OIDC_TRUST_EMAILTreat that provider’s email addresses as verified, so they can be matched to existing users. Only set it when the provider verifies every email address it issuesDefault: false
SSO_PASSWORD_LOGIN_DISABLEDTurns off email and password sign-in, sign-up and password resets for everyone except superadminsDefault: false
SSO_BUTTONS_VISIBLESet to false to hide the sign-in buttons until you’re ready. Add ?sso=1 to the sign-in page URL to see them while hiddenDefault: true
SSO_ALLOW_PRIVATE_ISSUERSAllows identity providers on private networks or plain http, such as an internal Keycloak. Never set this on an install where people you don’t trust can manage an account’s single sign-onDefault: false

Enterprise Features (Optional)

Reserved seating, the box office and per-account single sign-on need a licence key. See Enterprise Features for what’s included and how to get one.

Everything else is on for every account, and there are no feature flags to set. The admin area’s Feature Flags page is only used by Hi.Events Cloud and doesn’t appear on a self-hosted install.

Variable NameDescriptionExample
APP_LICENCE_KEYYour licence key. It removes the “Powered by Hi.Events” notice and unlocks the Enterprise features your licence includes. Unset leaves reserved seating, the box office and per-account single sign-on off. development unlocks them for development and testing onlyyour-licence-key
APP_SEAT_MAP_MAX_SEATSMaximum number of seats allowed in a single seat mapDefault: 3000

SaaS Configuration

Note

These variables are only relevant if you are using the SaaS version of Hi.Events.

Variable NameDescriptionExample
APP_SAAS_MODE_ENABLEDEnable SaaS mode. Defaults to falsetrue
APP_SAAS_STRIPE_APPLICATION_FEE_PERCENTStripe application fee percentage. Only relevant in SAAS mode1.5 for 1.5%
APP_SAAS_STRIPE_APPLICATION_FEE_FIXEDStripe application fee fixed. Only relevant in SAAS mode.40 for 40c
APP_SAAS_DEFAULT_PASS_PLATFORM_FEE_TO_BUYERWhether platform fees are passed on to the ticket buyer by defaultDefault: true
APP_STRIPE_CONNECT_ACCOUNT_TYPEStripe Connect account type used when organizers connect StripeDefault: express
OPEN_EXCHANGE_RATES_APP_IDOpen Exchange Rates App ID for currency conversionyour-app-id

In SaaS mode, payments are taken on each organizer’s connected Stripe account. Set your Stripe webhook to listen to events on connected accounts too, or orders never complete. account.updated and the payout.* events only matter in SaaS mode.

The SAAS fee variables seed the initial default platform fee configuration. They are not read on every order, so changing them later has no effect on existing organizers.

At checkout, the fee rates are read from the organizer’s assigned configuration (organizer_configurations.application_fees), which every new organizer inherits from the system default.

To change the rates after deployment, update the application_fees values on the relevant organizer_configurations row. Editing the system default row changes the rates inherited by organizers still using it.

The Platform Fees sections in Organizer Settings and Event Settings do not set rates — they only control who pays the fee. Organizer Settings sets the default for newly created events; Event Settings overrides that for a single event.

SaaS mode also emails APP_PLATFORM_SUPPORT_EMAIL a report of suspicious sign-up and ordering activity, at most every five minutes, which needs the scheduler.

Event moderation

SaaS mode only

Event moderation never runs unless APP_SAAS_MODE_ENABLED=true. It’s for installs where organizers you don’t know publish events. On a single-organization install, leave these unset.

In SaaS mode, Hi.Events can run published events through an automated spam check, and checks them again when their content changes. When an event is flagged, it’s moved to a PENDING_MANUAL_REVIEW status (hidden from the public, taking no orders and locked from status changes), the organizer is emailed, and a notification goes to APP_PLATFORM_SUPPORT_EMAIL. A superadmin then approves the event, or deletes the event or its whole account, from the Flagged Events page of the admin area. An event a superadmin has approved is never flagged automatically again; a later spam result only notifies support.

Variable NameDescriptionExample
APP_EVENT_SPAM_CHECK_ENABLEDEnable the automated checkDefault: false
APP_EVENT_SPAM_CHECK_CONFIDENCE_THRESHOLDMinimum confidence before an event is flagged. Raise it to flag lessDefault: 0.7
ANTHROPIC_API_KEYAPI key used for the checksk-ant-...

The check runs only when SaaS mode is on, APP_EVENT_SPAM_CHECK_ENABLED=true and ANTHROPIC_API_KEY is set. If any one is missing, events publish as normal with no check. It runs as a queued job, so it also needs a queue worker; with QUEUE_CONNECTION=sync it runs inline and slows down publishing.

To re-run the check across existing live events (after changing the threshold, for example), use the command below. --account-id= limits it to one account and --dry-run reports what it would check without checking.

php artisan events:recheck-spam

AWS Configuration

These variables are required if you’d like to use AWS S3 for file storage. You can also use other s3-compatible services like DigitalOcean Spaces.

Production note

To avoid losing files during updates or server failures, we highly recommend using cloud file storage for production deployments.

Variable NameDescriptionExample
AWS_ACCESS_KEY_IDAWS access key IDyour-access-key-id
AWS_SECRET_ACCESS_KEYAWS secret access keyyour-secret-access-key
AWS_DEFAULT_REGIONAWS regionus-west-1
AWS_PUBLIC_BUCKETAWS public bucket nameyour-public-bucket
AWS_PRIVATE_BUCKETAWS private bucket nameyour-private-bucket
AWS_ENDPOINTCustom S3 API endpoint. Required for any non-AWS S3-compatible provider — without it the AWS SDK talks to real AWS S3https://nyc3.digitaloceanspaces.com
AWS_USE_PATH_STYLE_ENDPOINTUse path-style bucket URLs (endpoint/bucket/key) instead of virtual-hosted style. Required by MinIO and some other providersDefault: false
AWS_URLPublic base URL that stored files are served from. Set this if your files are served from a CDN or custom domain rather than the bucket endpointhttps://cdn.your-app.com

Database Configuration

You can either set individual database configuration variables or use the DATABASE_URL to simplify the configuration.

Variable NameDescriptionExample
DB_CONNECTIONDatabase connection typepgsql
DB_HOSTDatabase hostyour-database-host
DB_PORTDatabase port5432
DB_DATABASEDatabase nameyour-database-name
DB_USERNAMEDatabase usernameyour-database-username
DB_PASSWORDDatabase passwordyour-database-password
DATABASE_URLDatabase URL (alternative to individual values)postgres://user:password@host:port/database

Redis Configuration

Variable NameDescriptionExample
REDIS_HOSTRedis hostyour-redis-host
REDIS_PASSWORDRedis passwordyour-redis-password
REDIS_USERNAMERedis usernameyour-redis-username
REDIS_PORTRedis port6379
REDIS_URLRedis URLredis://user:password@host:port

Queue Configuration

Variable NameDescriptionExample
QUEUE_CONNECTIONQueue connection typeDefault: sync. Set to redis for production deployments.
WEBHOOK_QUEUE_NAMEName of the queue that outgoing webhook jobs are dispatched to. When unset, it falls back to the value of QUEUE_CONNECTION (for example redis). Set it whenever QUEUE_CONNECTION is not syncwebhook-queue
OCCURRENCES_QUEUE_NAMEName of the queue that recurring-event occurrence jobs (generating, cancelling and refunding occurrences) are dispatched to. Falls back to the default queue when unsetoccurrences

Set WEBHOOK_QUEUE_NAME when using a queue

Whenever QUEUE_CONNECTION is anything other than sync, set WEBHOOK_QUEUE_NAME=webhook-queue and make sure your worker consumes that queue (see Running a Queue Worker). If it’s unset, webhook jobs go to a queue named after the connection (redis, for example), which the worker command on this page doesn’t listen on, so webhooks are never delivered.

Production note

For convenience, QUEUE_CONNECTION is set to sync by default. It is highly recommended to use a queue system like Redis for production deployments.

The QUEUE_CONNECTION=redis and WEBHOOK_QUEUE_NAME=webhook-queue values come from the provided .env.example and docker-compose.yml respectively, not from the all-in-one image itself, and the Redis server is a separate compose service. If you run the all-in-one image directly (docker run, Kubernetes, a PaaS) rather than via that compose file, you must supply these environment variables yourself and point REDIS_HOST at a reachable Redis instance. You must also define VITE_FRONTEND_URL, VITE_API_URL_CLIENT and VITE_STRIPE_PUBLISHABLE_KEY (empty is fine for the Stripe key), or the container exits after running migrations.

Cache and Session Configuration

Variable NameDescriptionExample
CACHE_DRIVERCache store driverDefault: file. Set to redis for production deployments.
SESSION_DRIVERSession driverDefault: file. Set to redis for production deployments.

Use redis if you run more than one backend instance

Both default to file, which stores data on each container’s own filesystem. Stripe webhook idempotency is enforced through the cache, so with the file driver and multiple backend replicas the same Stripe event can be processed more than once — potentially producing duplicate orders or refunds. Homepage ticket-quantity caching is also per-instance under file.

Two-factor sign-in also uses the cache: the password step and the code step can land on different instances, so with file, users who have two-factor authentication on can be told their sign-in has expired after entering a correct code. Single sign-on keeps each sign-in’s state in the cache too, and fails the same way.

For any deployment running more than one backend instance, set CACHE_DRIVER=redis (and SESSION_DRIVER=redis).

Database Setup and Migrations

Hi.Events won’t start until its database schema has been created. Migrations must also be re-run after every upgrade.

The all-in-one Docker image handles this for you: its startup script runs php artisan migrate --force (and aborts startup if migrations fail) followed by php artisan storage:link on every container start. No action is needed.

For separate image deployments and manual setups you must run these yourself. The hi.events-backend image does not run them on start.

Once your database environment variables are configured, run from the backend directory:

php artisan migrate --force

Then, only if you’re using local disk storage (FILESYSTEM_PUBLIC_DISK=public), create the symlink that makes uploaded files publicly reachable:

php artisan storage:link

If you’re running the backend in a Docker container, run these via docker exec:

docker exec your-backend-container php artisan migrate --force
docker exec your-backend-container php artisan storage:link

On the all-in-one image, Artisan lives at /app/backend/artisan, so use php /app/backend/artisan … in this and every other docker exec command on this page.

Run migrations on every upgrade

Deploying a new version of the backend image without re-running php artisan migrate --force will leave the application running against an out-of-date schema. Make it part of your deploy process — for example as an init container, a release-phase command, or a one-off job that runs before the new backend pods start serving traffic.

--force is required because migrations are otherwise refused when APP_ENV=production.

Upgrading to 2.0

Back up your database before upgrading from 1.x, then run php artisan migrate --force as above (the all-in-one image does it on start). Pin the image to a release tag, such as v2.0.0-rc.2: the latest tag only moves on full releases, not release candidates. Among other things, the 2.0 migrations add tables and columns for recurring events, venue locations, product add-ons, the box office and reserved seating, two-factor authentication, team access controls, lineup and schedule, saved widgets, single sign-on identities and connections, API keys and email delivery tracking. They also make the user password column nullable, for people who only sign in with single sign-on.

Several of the 2.0 migrations build indexes with CREATE INDEX CONCURRENTLY, outside a transaction. That doesn’t lock the tables, but on a large database it can take a while. If one is interrupted, PostgreSQL can leave an invalid index behind, which most of these migrations skip when they run again. Drop any index PostgreSQL lists as invalid with DROP INDEX CONCURRENTLY, then rerun the migrations.

After upgrading, check that both the queue worker and the scheduler are running: more of 2.0 depends on the scheduler than 1.x did.

Running a Queue Worker

If you set QUEUE_CONNECTION to anything other than sync, a queue worker must be running or queued work — emails, webhooks, and occurrence generation for recurring events — will never be processed.

The all-in-one Docker image runs a worker automatically via Supervisor. That worker listens on the default and webhook-queue queues only, so leave OCCURRENCES_QUEUE_NAME unset there. For separate image deployments or manual setups, run:

php artisan queue:work --queue=default,webhook-queue,occurrences --sleep=3 --tries=3 --timeout=60

Queue names must match your configuration

The queues listed after --queue= must match the values of WEBHOOK_QUEUE_NAME and OCCURRENCES_QUEUE_NAME. The command above assumes WEBHOOK_QUEUE_NAME=webhook-queue and OCCURRENCES_QUEUE_NAME=occurrences. Leaving OCCURRENCES_QUEUE_NAME unset is fine, because those jobs then go to default. Leaving WEBHOOK_QUEUE_NAME unset isn’t: webhook jobs go to a queue named after QUEUE_CONNECTION, which this worker doesn’t listen on.

Keep this process alive with a process manager such as Supervisor or systemd. See the Laravel Queues documentation for details.

Running the Scheduler

Hi.Events uses Laravel’s task scheduler for recurring background tasks. If the scheduler isn’t running, these features won’t work:

TaskFrequencyWhat breaks without the scheduler
Scheduled messagesEvery minuteMessages scheduled for a future time are never sent
Expired waitlist offersEvery minuteWaitlist offers never expire, so the released capacity is never offered to the next person in line
Scheduled account deletionsHourlyAccount deletions requested through Danger Zone never complete after their 30-day grace period
Failed jobs monitorEvery 5 minutesNo warning is logged when jobs start failing and piling up in the queue
Single sign-on domain checkDailyDomains verified for per-account single sign-on are never rechecked, so a domain whose DNS record was removed is never suspended
API key expiry remindersDaily, 09:00 UTCPeople aren’t emailed a week before their API keys expire
Expired API key clean-upDailyAPI keys that expired more than 30 days ago are never deleted
Suspicious activity reportEvery 5 minutesSaaS mode only: support is never emailed about suspicious sign-up and ordering activity
Lifecycle emailsHourlySaaS mode only, and only when MAIL_LIFECYCLE_MAILER is set: lifecycle nudge emails are never sent

Most of these tasks are pushed onto the queue when they’re due, so they also need a queue worker unless QUEUE_CONNECTION=sync.

The all-in-one Docker image (daveearley/hi.events-all-in-one) runs the scheduler automatically — no extra setup needed.

For separate image deployments or manual setups, you need to add a single cron entry on the server running your backend. This cron job calls Laravel’s schedule:run command every minute, and Laravel decides internally which scheduled tasks are due:

* * * * * cd /path-to-your-backend && php artisan schedule:run >> /dev/null 2>&1

Replace /path-to-your-backend with the actual path to your Hi.Events backend directory. If you’re running the backend in a Docker container, you can add this to the container’s crontab or run it via docker exec:

* * * * * docker exec your-backend-container php artisan schedule:run >> /dev/null 2>&1

You can verify the scheduler is working by running php artisan schedule:list — this shows all registered scheduled tasks and when they’re next due.

For more details on Laravel’s scheduler (daemon mode, running without cron, etc.), see the Laravel Scheduling documentation.

Resetting Two-Factor Authentication

Users turn on two-factor authentication from Settings → Security, and account admins can require it for their team under Settings → Team. When a user loses both their authenticator app and their recovery codes, there are three ways to reset it:

  • An Admin on their account can use Reset two-factor on the member’s row under Settings → Team. This only works for users who belong to that one account, and only the account owner can reset the owner
  • A superadmin can reset anyone with Reset 2FA on the Users page of the admin area
  • From the server, run:
php artisan user:reset-two-factor [email protected]

Or in Docker:

docker exec -it your-backend-container php artisan user:reset-two-factor [email protected]

The command asks for confirmation, then turns off two-factor authentication, deletes the user’s recovery codes and trusted devices, and emails the user. Confirm the request really came from them before running it. After a reset they can sign in with just their password, and if their account requires two-factor authentication they’re asked to set it up again.

The command and the admin area both refuse to reset the owner of an account that requires single sign-on, because two-factor authentication is the owner’s backup way in. The owner has to stop requiring single sign-on first.

Setting Up Single Sign-On

Hi.Events supports single sign-on through OpenID Connect at two levels:

Instance-wide providersPer-account connections
What it isGoogle, Microsoft and one other OpenID Connect provider, with buttons on the sign-in and sign-up pages for everyoneAn account’s own Okta, Microsoft Entra ID, Google Workspace or other OpenID Connect provider. The account can verify its email domains and require its members to sign in through it
Who sets it upYou, with environment variables (see Single Sign-On above)Account admins, under Settings → General → Single sign-on
LicenceNone: part of the open-source coreA paid licence (Enterprise feature)

SAML and SCIM provisioning aren’t supported.

Instance-Wide Providers

  1. Register an app with each provider and add this redirect URI: <APP_FRONTEND_URL>/sso/callback, for example https://tickets.example.com/sso/callback. Per-account connections use the same redirect URI
  2. Set the provider’s client ID and secret on the backend, then redeploy so it picks them up
  3. Check the setup:
php artisan sso:status

It prints the redirect URI to register, whether password sign-in is on, and whether each provider’s discovery document can be reached. Exit code 1 means a provider didn’t respond.

Provider notes:

  • Microsoft: allow both work and personal accounts in the app registration, and add the optional email and xms_edov ID token claims. Without xms_edov, verified work emails can’t be matched to existing users
  • Other OpenID Connect providers: set SSO_OIDC_NAME so the button doesn’t just say “SSO”. Only set SSO_OIDC_TRUST_EMAIL=true if the provider verifies every email address it issues; otherwise anyone who can create an account at the provider with someone else’s email could sign in as them
  • Providers on a private network (an internal Keycloak, for example) are blocked unless you set SSO_ALLOW_PRIVATE_ISSUERS=true. That setting applies to per-account connections too, so never set it on an install where other organizations manage their own single sign-on

New people can create an account through a provider unless APP_DISABLE_REGISTRATION=true, which blocks sign-ups of both kinds. Invited team members can still join.

Turning Off Password Sign-In

SSO_PASSWORD_LOGIN_DISABLED=true makes everyone except superadmins sign in through single sign-on: password sign-in, password sign-up and password resets stop working for them. Existing users can sign in once their provider’s email is verified and matches their account, and sso:status warns you how many users haven’t signed in with single sign-on yet.

Superadmins keep password sign-in as a way back in, so make sure at least one exists first (php artisan user:make-superadmin {userId}). If your provider is down and you need password sign-in for everyone again, unset SSO_PASSWORD_LOGIN_DISABLED and redeploy.

Per-Account Connections and Domains

Per-account connections need APP_LICENCE_KEY set to a valid licence; the server needs no other configuration. With no licence and no connection already set up, the Single sign-on section doesn’t appear under Settings → General. If a licence lapses after its grace period, people keep signing in through connections that are already on, and admins can still view, turn off or delete them, but can’t set up or turn on anything new (see If a Licence Expires).

To require single sign-on, an account verifies each of its email domains by adding a DNS TXT record at _hievents-verify.<domain>. The setup screen shows the value. A daily scheduled job rechecks every verified domain and suspends a domain seven days after its record stops resolving. Each domain can be verified by one account only.

If you’ve confirmed who owns a domain some other way, or two accounts are claiming the same one, verify it from the server:

php artisan sso:verify-domain example.com --account=123

--account is only needed when more than one account has added the domain. If another account had it verified, the command removes it from that account, and warns you if that account required single sign-on. The TXT record still needs to stay in place, or the daily check suspends the domain.

The account owner is never forced through the account’s connection: they keep password sign-in, which always asks for two-factor authentication while the account requires single sign-on. If the account’s identity provider stops working, the owner signs in with their password and turns enforcement off.

Removing a User’s Single Sign-On

If someone’s identity provider account was compromised or replaced, or they’re locked out because it’s gone, remove their single sign-on logins and sign them out everywhere:

php artisan sso:unlink-identity [email protected]

Or in Docker:

docker exec -it your-backend-container php artisan sso:unlink-identity [email protected]

The command asks for confirmation. Afterwards the user can set a password with Forgot password (unless SSO_PASSWORD_LOGIN_DISABLED is on), or link a provider again.

Important Deployment Considerations

Common Issues

  • Environment variable misconfiguration is the most common cause of deployment problems
  • Performance issues typically stem from underpowered hardware or incorrectly configured queues
  • In most cloud environments, the filesystem is ephemeral - uploaded files will be lost on redeploy unless you use cloud storage

Checklist Before Going Live

  • All environment variables are properly configured
  • Outgoing mail works (invitations and password resets go by email, and in SaaS mode people must confirm their email address before they can publish events, invite team members or create API keys)
  • A queue worker and the scheduler are running (see Running a Queue Worker and Running the Scheduler)
  • APP_TRUSTED_PROXIES lists your proxies if you run behind more than one (see Reverse Proxies and Client IPs)
  • File storage solution is configured (local vs cloud storage)
  • SSL certificates are installed and valid
  • Backups are configured
  • Monitoring is in place

If you’re planning to host events that might experience sudden bursts of traffic (like popular ticket sales or flash sales), you’ll want to take extra precautions with your deployment:

Tips for High Traffic Events

When expecting high traffic:

  • Ensure you have queues set up for background processing. Do not set QUEUE_CONNECTION to sync.
  • Use a more powerful database instance, as this is typically the bottleneck
  • Consider using a CDN for static assets
  • Monitor your infrastructure metrics during the event
  • Have a plan in place for scaling up if needed
  • Consider using a managed database service for better scalability
  • Use a load balancer to distribute traffic