Skip to content

Addons

Addons are backing services — databases, caches, object storage — that your app depends on. Hop3 manages their lifecycle (create, attach, detach, destroy) and injects the connection details into your app as environment variables on deploy.

This guide covers the four addons shipped in Hop3 0.5 — postgres, mysql, redis, and s3 — plus the experimental email (SMTP relay) addon added in 0.6.1.

Quick Start

The simplest way to use an addon is to declare it in hop3.toml. On first deploy, the addon is auto-provisioned; on subsequent deploys it's reused.

[metadata]
id = "my-django-app"

[run]
start = "gunicorn myapp.wsgi --bind 0.0.0.0:$PORT"

[[addons]]
type = "postgres"

[[addons]]
type = "redis"

After hop3 deploy, your app has DATABASE_URL and REDIS_URL in its environment — no further wiring needed.

CLI Workflow

When you need imperative control (multi-app sharing, ad-hoc provisioning, destruction), use the addon commands.

Create an addon

hop3 addon create postgres my-database
hop3 addon create redis my-cache
hop3 addon create mysql legacy-db
hop3 addon create s3 uploads

The addon name is free-form; pick one that describes the data, not the app. This keeps the name stable across apps that share the addon.

Attach to an app

hop3 addon attach my-database --app my-app
hop3 addon attach my-cache --app my-app

Attach injects the addon's env vars into the app on the next deploy or restart. An addon can be attached to multiple apps (shared database pattern).

Detach and destroy

hop3 addon detach my-database --app my-app     # Remove env vars; keep data
hop3 addon destroy my-database                 # Permanently delete (prompts)

destroy requires typed confirmation of the addon name. Pass --confirm=<name> to skip the interactive prompt in scripts. --force skips all safety checks.

List and inspect

hop3 addon list                        # All provisioned instances (alias: addons)
hop3 addon list --app my-app           # Only addons attached to my-app
hop3 addon types                       # Addon types you can create
hop3 addon show my-database            # Full details for one addon
hop3 addon status my-database          # Health and connection check

Type-specific commands

Each addon type adds a few operations under hop3 addon <type> <verb> <name> (the type is part of the command path, so no --type flag):

# Credentials (all types) — prints the connection env vars; treat as sensitive
hop3 addon postgres credentials my-database
hop3 addon redis credentials my-cache

# Dump / restore (all four types support both)
hop3 addon postgres dump my-database              # pg_dump → server backup dir
hop3 addon postgres restore my-database <path>    # psql restore (prompts)
hop3 addon mysql dump legacy-db
hop3 addon redis dump my-cache
hop3 addon redis restore my-cache <path>

# Postgres extensions (allow-listed)
hop3 addon postgres extensions my-database postgis pgvector

# Redis flush — empties the database, keeps the addon (prompts)
hop3 addon redis flush my-cache

# Ad-hoc query — runs as the addon's own (least-privilege) user
hop3 addon postgres query my-database --command "SELECT count(*) FROM users"
hop3 addon redis query my-cache --command "DBSIZE"

See the CLI reference for the full per-type verb matrix.

Addon Reference

postgres

Provisions a PostgreSQL database owned by a per-app user with CREATE ON DATABASE + CREATE, USAGE ON SCHEMA public grants, so migrations can install trusted extensions (pg_trgm, uuid-ossp, citext) without superuser help.

Injected env vars:

Variable Example
DATABASE_URL postgres://myapp:pass@localhost:5432/myapp
PGDATABASE myapp
PGUSER myapp
PGPASSWORD (generated)
PGHOST localhost
PGPORT 5432

Non-trusted extensions (e.g. postgis, pgvector, bloom) cannot be installed by the per-app user. Declare them in hop3.toml and Hop3 will install them as superuser at provisioning time:

[[addons]]
type = "postgres"
extensions = ["postgis", "pgvector"]

Hop3 enforces an allow-list. The default set covers the PG13+ trusted extensions (pg_trgm, hstore, citext, pgcrypto, uuid-ossp, …) plus widely-used non-trusted ones audited to carry no privilege-escalation surface (postgis, pgvector, bloom, cube, earthdistance, ip4r, pg_stat_statements). To enable an additional extension on a specific Hop3 install — e.g. pg_partman or a vendor extension — set the operator-side env var HOP3_EXTRA_PG_EXTENSIONS (comma-separated). A small hard-deny set (postgres_fdw, dblink, file_fdw, adminpack, untrusted PL languages) cannot be enabled even via the override; those grant filesystem / network / arbitrary-code-execution capability and would defeat the separation between "deploy an app" and "execute as the postgres superuser".

Some extensions (pg_cron, timescaledb) require shared_preload_libraries and a Postgres restart on top of CREATE EXTENSION. Hop3's default installer does not yet pre-load arbitrary extensions; treat those as a separate setup step.

mysql

Injected env vars:

Variable Example
DATABASE_URL mysql://myapp:pass@localhost:3306/myapp
MYSQL_DATABASE myapp
MYSQL_USER myapp
MYSQL_PASSWORD (generated)
MYSQL_HOST localhost
MYSQL_PORT 3306

Docker apps: Hop3 grants MySQL access on multiple host patterns (@'localhost', @'127.0.0.1', @'10.%', @'172.%', @'192.168.%') so apps reaching the host MySQL from any of the private ranges a Docker network might use authenticate correctly. This is automatic, but it's worth knowing if you inspect the mysql.user table.

redis

A logical Redis database (numeric, 0–15 by default) is allocated per addon instance; multiple addons can coexist on the same Redis server.

Injected env vars:

Variable Example
REDIS_URL redis://127.0.0.1:6379/2
REDIS_HOST 127.0.0.1
REDIS_PORT 6379
REDIS_DB 2

Note: REDIS_HOST is 127.0.0.1 (not localhost) to avoid IPv6 resolution issues. For Docker apps, the Docker deployer rewrites 127.0.0.1 → host.docker.internal at deploy time.

s3

Provisions a bucket (named hop3-<addon-name>) and a scoped access key on the configured S3 backend (MinIO in 0.5; Garage planned for 0.6).

Injected env vars:

Variable Example
S3_ENDPOINT http://localhost:9000
S3_BUCKET hop3-uploads
S3_ACCESS_KEY (generated)
S3_SECRET_KEY (generated)
S3_REGION us-east-1
S3_USE_PATH_STYLE true

Path-style URLs are required for MinIO; virtual-host style will arrive with the Garage backend.

email (experimental)

Experimental (added 0.6.1; backend model reworked in 0.7). The command surface is subject to change. Every addon email / server email command prints a one-line experimental banner.

Email is a backing service with a swappable backend, like the database addons. You pick a backend once at the server level; an app opts in by attaching an email addon and sends over a platform-owned loopback SMTP endpoint (127.0.0.1:25) — a queuing Postfix relay that forwards to whatever the backend is. Apps never shell sendmail (a local-binary side-channel isn't config-driven), and the provider credential never enters an app's environment. It is outbound transactional email only: no inbound, IMAP, or MX.

Install the local relay with the email feature (it's in --with all):

hop3-install server --with email

Pick a backend (admin-only):

  • relay — submit to a provider or corporate smarthost (Scaleway TEM, SES, SendGrid, Mailgun, Brevo, …); the provider owns deliverability. The recommended production backend — also spelled server email set.
  • catch — a local dev sink: mail is captured, never sent. The safe non-production default.
  • direct (preview) — a Hop3-run MTA that delivers to recipients itself and signs with DKIM, no third party (the sovereign path). Hop3 generates the DKIM keypair and prints the SPF/DKIM/DMARC records to publish. Preview in 0.7: it needs a systemd host with outbound port 25 unblocked (it fails loud otherwise), and fresh-IP reputation is your responsibility — full support lands in 0.8.

Name a known provider with --provider and Hop3 fills the SMTP host/port (--list-providers: Resend, Postmark, Brevo, Mailgun / Mailgun-EU, Scaleway TEM; EU-hosted ones flagged):

hop3 server email backend relay --provider brevo \
    --smtp-user <user> --smtp-password @./smtp.secret \
    --from-domain example.com
# or spell out the endpoint yourself:
hop3 server email backend relay --smtp-host smtp.example.com --smtp-user u \
    --smtp-password @./pw --from-domain example.com --dkim-selector s1
# a dev sink — nothing is sent:
hop3 server email backend catch --from-domain example.com
# a self-hosted MTA — prints the DKIM/SPF/DMARC records to publish:
hop3 server email backend direct --from-domain example.com

hop3 server email status        # active backend + a live SPF/DKIM/DMARC pre-flight

Keep the password out of your shell history (ADR 036): --smtp-password @<path> reads it from a file, --smtp-password - reads it from stdin. Selecting a backend is idempotent and re-pointable — swapping it never re-touches an app.

Attach it to an app. An app declares it needs email by creating an addon without --smtp-* (so it inherits the backend) and attaching it (the type isn't inferred from the name, so pass --type email):

hop3 addon email create mail --from noreply@example.com   # inherits the backend
hop3 addon attach mail --app my-app --type email          # injects SMTP_HOST=127.0.0.1
hop3 addon email status mail                              # never the password

The app is injected SMTP_HOST=127.0.0.1 / SMTP_PORT=25 (no auth) and relays through the loopback Postfix; the provider credential stays in Postfix. Inheriting is refused if --from is not on the backend's verified sending domain. An app can bring its own provider by passing --smtp-* (all three creds or none), which sends to that provider directly instead of the shared relay.

Get alerted when things break. Reuse the backend to email yourself on server events (cert-renewal failures and failed deploys today; more with the monitoring roadmap) — opt-in, admin-only. The recipient defaults to your ACME email:

hop3 server email notifications on --to ops@example.com
hop3 server email notifications test        # send a test message now
hop3 server email notifications status      # enabled? and actually deliverable?

status tells you loudly if notifications are on but can't send (no backend / no recipient), so a misconfigured alert channel never stays silently broken.

Injected env vars — one endpoint, every common spelling. An inheriting app points at the loopback (SMTP_HOST=127.0.0.1, SMTP_PORT=25, no auth); an own-provider app points at its provider directly:

Variables Consumer
SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_FROM, SMTP_TLS neutral / Node
SMTP_URL (smtp://…) Node / nodemailer, URL parsers
EMAIL_HOST, EMAIL_PORT, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, EMAIL_USE_TLS, EMAIL_USE_SSL, DEFAULT_FROM_EMAIL Django (django.core.mail)
MAIL_SERVER, MAIL_PORT, MAIL_USERNAME, MAIL_PASSWORD, MAIL_USE_TLS, MAIL_USE_SSL, MAIL_DEFAULT_SENDER Flask-Mail

So Django, Flask-Mail, and Node/nodemailer read their native names directly — no remapping.

Frameworks that read no SMTP env are pointed at the same 127.0.0.1:25 endpoint through their own config — never sendmail:

  • Rails (ActionMailer) — in config/environments/production.rb, read the injected vars:
    config.action_mailer.smtp_settings = {
      address: ENV["SMTP_HOST"], port: ENV["SMTP_PORT"].to_i,
    }
    
  • WordPress — stock wp_mail() uses PHP mail(). Hop3's WordPress packaging drops a must-use plugin (a phpmailer_init hook) that routes wp_mail() through the attached email addon's SMTP_HOST/SMTP_PORT automatically, and self-disables when no email addon is attached. For a custom app, write the equivalent from before-run — never sendmail.

Email is configured imperatively, so don't declare it in [[addons]]: a declarative block has no credentials to provision, and the deploy fails loud telling you to run addon email create.

Deliverability is your job, at the provider

For a relay backend Hop3 hands the message to your provider; for direct it delivers itself. Either way, whether it reaches the inbox is gated by DNS on your sending domain, not by Hop3:

  • Publish SPF, DKIM, and DMARC for the From-domain (for a relay, your provider's dashboard generates the exact records; for direct, Hop3 generates the DKIM keypair and prints the records to publish). Since 2024, Gmail/Yahoo/Microsoft reject or spam-folder unauthenticated mail.
  • The unit you authenticate is the domain, not the address. Once example.com is verified, any From on it — noreply@, support@, billing@ — sends for free.
  • Sending as support@example.com does not mean Hop3 hosts that mailbox; replies follow your domain's existing MX (Google Workspace, etc.).

server email status checks SPF and DMARC for the domain via DNS and reports what's missing — never claiming "ready" over unpublished records. DKIM is auto-verified too once its selector is known: pass --dkim-selector <sel> (copy it from your provider's dashboard), or use --provider resend (Resend's selector is fixed, so it's supplied automatically). Without a selector the DKIM row stays guidance — Hop3 never guesses a selector and reports a fake "missing".

Wiring it into apps (recipes)

Attaching the addon injects the superset above. A few apps read those names directly; most read their own names and need a short [env] remap (Hop3's ${VAR} interpolation, resolved after addon injection). The injected values follow the backend the app inherits — for the shared backends that's the loopback (127.0.0.1:25, no auth, no TLS; the local relay does auth/TLS upstream); for an own---smtp-* app it's your provider (587/STARTTLS + creds). Two recurring gotchas:

  • TLS / auth flags: toward the loopback, keep encryption and auth off (SMTP_TLS is injected false, SMTP_USER/SMTP_PASSWORD empty) — an app that hardcodes STARTTLS/465 or requires a username toward 127.0.0.1 will fail. For an own-provider app, SMTP_TLS means STARTTLS on 587; an implicit-TLS/465 or enum flag (SMTP_SECURITY, …) must be set literally, not piped from SMTP_TLS.
  • Combined host:port: Grafana and Gitea want one host:port string — splice it with [env.computed] (e.g. GF_SMTP_HOST = "${SMTP_HOST}:${SMTP_PORT}").

Works on attach (no remap): BookWyrm, Bugsink (Django EMAIL_*).

Laravel (Monica, BookStack, Invoice Ninja):

[env]
MAIL_MAILER = "smtp"
MAIL_HOST = "${SMTP_HOST}"
MAIL_PORT = "${SMTP_PORT}"
MAIL_USERNAME = "${SMTP_USER}"       # empty toward the loopback backend
MAIL_PASSWORD = "${SMTP_PASSWORD}"   # empty toward the loopback backend
MAIL_ENCRYPTION = ""                 # empty for the loopback; "tls" for an own-provider (587)
MAIL_FROM_ADDRESS = "${SMTP_FROM}"   # BookStack uses MAIL_FROM instead

Bespoke-prefix apps — the [env] lines beyond a bare attach:

App [env] remap (values are ${SMTP_*} unless quoted literal)
GoToSocial GTS_SMTP_HOST, GTS_SMTP_PORT, GTS_SMTP_USERNAME=${SMTP_USER}, GTS_SMTP_PASSWORD, GTS_SMTP_FROM
Vikunja VIKUNJA_MAILER_ENABLED="true", _HOST, _PORT, _USERNAME=${SMTP_USER}, _PASSWORD, _FROMEMAIL=${SMTP_FROM}
Forgejo / Gitea GITEA__mailer__ENABLED="true", __PROTOCOL="smtp+starttls", __SMTP_ADDR=${SMTP_HOST}, __SMTP_PORT, __USER, __PASSWD=${SMTP_PASSWORD}, __FROM=${SMTP_FROM}
Discourse DISCOURSE_SMTP_ADDRESS=${SMTP_HOST}, _PORT, _USER_NAME=${SMTP_USER}, _PASSWORD, _ENABLE_START_TLS="true", DISCOURSE_NOTIFICATION_EMAIL=${SMTP_FROM}
Mattermost MM_EMAILSETTINGS_SMTPSERVER=${SMTP_HOST}, _SMTPPORT, _SMTPUSERNAME=${SMTP_USER}, _SMTPPASSWORD, _ENABLESMTPAUTH="true", _CONNECTIONSECURITY="STARTTLS", _SENDEMAILNOTIFICATIONS="true", _FEEDBACKEMAIL=${SMTP_FROM}
Mastodon SMTP_SERVER=${SMTP_HOST}, SMTP_LOGIN=${SMTP_USER}, SMTP_FROM_ADDRESS=${SMTP_FROM} (SMTP_PORT/SMTP_PASSWORD already match)
Directus EMAIL_TRANSPORT="smtp", EMAIL_SMTP_HOST=${SMTP_HOST}, _PORT, _USER=${SMTP_USER}, _PASSWORD, EMAIL_FROM=${SMTP_FROM}, EMAIL_SMTP_SECURE="false"
Formbricks core SMTP_HOST/PORT/USER/PASSWORD direct; add MAIL_FROM=${SMTP_FROM}, SMTP_SECURE_ENABLED="0"
Vaultwarden SMTP_HOST/PORT/FROM direct; add SMTP_USERNAME=${SMTP_USER}, SMTP_SECURITY="starttls"
Grafana GF_SMTP_ENABLED="true", GF_SMTP_USER=${SMTP_USER}, GF_SMTP_PASSWORD, GF_SMTP_FROM_ADDRESS=${SMTP_FROM}, and [env.computed] GF_SMTP_HOST="${SMTP_HOST}:${SMTP_PORT}"
GlitchTip EMAIL_URL="smtp://${SMTP_USER}:${SMTP_PASSWORD}@${SMTP_HOST}:${SMTP_PORT}" (URL-encode user/pass if they contain @ : /)

A few apps ship with their mailer toggled off in Hop3's generated config (Forgejo/Gitea [mailer] ENABLED=false, Vikunja VIKUNJA_MAILER_ENABLED=false); the remap above only takes effect once that default is cleared.

Not yet reachable by env: apps that read SMTP from a config file or DB — Nextcloud, Matrix-Synapse, MediaWiki, Keycloak, Wiki.js, Redmine, Dolibarr, Kanboard, LimeSurvey, Ghost — can't be wired by [env] today. They need a per-app config-file/post-deploy step; see the internal catalog-fit note.

Common Patterns

Mapping to app-specific variable names

If your app expects variables like DB_HOST or CACHE_URL rather than the canonical Hop3 names, remap in [env]:

[env]
DB_HOST = "${PGHOST}"
DB_PORT = "${PGPORT}"
DB_NAME = "${PGDATABASE}"
DB_USER = "${PGUSER}"
DB_PASS = "${PGPASSWORD}"
CACHE_URL = "${REDIS_URL}"

Or — for apps that translate only at runtime (e.g. Django reading POSTGRES_USER) — wrap the start command:

[run]
start = "env POSTGRES_USER=$PGUSER POSTGRES_PASSWORD=$PGPASSWORD POSTGRES_DB=$PGDATABASE POSTGRES_HOST=$PGHOST POSTGRES_PORT=$PGPORT gunicorn myapp.wsgi:application --bind 0.0.0.0:$PORT"

Sharing an addon across apps

hop3 addon create postgres shared-db
hop3 addon attach shared-db --app api
hop3 addon attach shared-db --app worker
hop3 addon attach shared-db --app admin

Each app receives the same DATABASE_URL and can coordinate via the shared database.

Multiple addons of the same type

Declare one [[addons]] block per instance:

[[addons]]
type = "redis"
name = "session-store"

[[addons]]
type = "redis"
name = "job-queue"

When several addons of the same type are attached to one app, one is the primary and injects the canonical, unprefixed variables (REDIS_URL, etc.); each additional instance injects the same keys prefixed with its uppercased name (e.g. JOB_QUEUE_REDIS_URL). A single attached addon is always primary. Use hop3 addon promote <name> --app <app> to choose which instance is primary.

Backup and Restore

Addons are included in app-level backups by default. See Backup & Restore.

hop3 backup create --app my-app           # Includes attached addon data
hop3 backup create --app my-app --no-addons   # App code + env only

For a single addon, hop3 addon <type> dump <name> writes a standalone dump to the server's backup area, and hop3 addon <type> restore <name> <path> restores it. All four types support both verbs.

See Also