English
Self-hosted
Three ways to run trip2g on your own server. Pick the one that fits your setup.
★ GitHub — github.com/trip2g/trip2g
| Variant | Best for |
|---|---|
| Single binary on bare Linux | One site on one VM. No Docker, no Caddy, no MinIO. Built-in HTTPS via Let's Encrypt. |
| Docker Compose (full stack) | You want S3-compatible object storage and a managed reverse proxy. |
| fly.io | Managed PaaS, no server to maintain. See en/user/fly. |
Single binary on bare Linux
One binary, one systemd unit, HTTPS out of the box. No containers required.
The trip2g-server binary embeds all frontend assets. The only host dependencies are git and ca-certificates.
Get the binary
Fastest — one-line installer (fetches the latest release, writes the config, installs a systemd unit, sets up HTTPS, prints a one-time sign-in link):
curl -fsSL https://raw.githubusercontent.com/trip2g/trip2g/main/scripts/simple-install.sh | sh
Or do it by hand. Three options, in order of preference:
(a) Download from GitHub Releases (recommended):
# Replace <version> with the latest tag from https://github.com/trip2g/trip2g/releases
curl -L https://github.com/trip2g/trip2g/releases/download/<version>/trip2g_<version>_linux_amd64.tar.gz \
| tar xz trip2g-server
sudo mv trip2g-server /usr/local/bin/trip2g
sudo chmod +x /usr/local/bin/trip2g
(b) Extract from the Docker image:
docker create --name tmp ghcr.io/trip2g/trip2g && \
docker cp tmp:/trip2g /usr/local/bin/trip2g && \
docker rm tmp
sudo chmod +x /usr/local/bin/trip2g
(c) Build from source — note: the frontend assets must already be built into assets/ui before this (the Dockerfile does that with the $mol builder). Options (a) and (b) are self-contained; (c) is only for a full checkout with the frontend built:
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build ./cmd/server
sudo mv server /usr/local/bin/trip2g
sudo chmod +x /usr/local/bin/trip2g
Install runtime dependencies
apt-get update && apt-get install -y git ca-certificates
The binary shells out to git internally and needs CA certificates for TLS verification.
Create data directories
mkdir -p /var/lib/trip2g/storage
Configure
Create /etc/trip2g.env and set permissions so only root can read it:
touch /etc/trip2g.env
chmod 600 /etc/trip2g.env
Paste and fill in the values:
ACME_DOMAIN=docs.example.com
PUBLIC_URL=https://docs.example.com
INTERNAL_LISTEN_ADDR=:8082
DB_FILE=/var/lib/trip2g/data.sqlite3
GIT_API_REPO_PATH=/var/lib/trip2g/git
STORAGE_BACKEND=local
STORAGE_LOCAL_DIR=/var/lib/trip2g/storage
LOG_LEVEL=info
DEV=false
OWNER_EMAIL=owner@example.com
JWT_SECRET=<openssl rand -hex 32>
DATA_ENCRYPTION_KEY=<openssl rand -hex 16>
SMTP_HOST=smtp.resend.com
SMTP_USER=resend
SMTP_PASS=<resend api key>
MAIL_FROM=no-reply@your-verified-domain
What each setting does:
ACME_DOMAIN— the domain to get a Let's Encrypt certificate for. The binary listens on:443with TLS-ALPN-01 and runs an HTTP→HTTPS redirect on:80. No Caddy needed.PUBLIC_URL— the external URL of your site, used in links and email flows.INTERNAL_LISTEN_ADDR— internal address for health checks (/healthz).DB_FILE— SQLite database path.GIT_API_REPO_PATH— path to trip2g's internal bare git repository.STORAGE_BACKEND=local— stores uploaded files on disk atSTORAGE_LOCAL_DIR. Files are served by trip2g itself at/_assets/.... To switch to S3 later, use the sameMINIO_*variables as the Compose variant.LOG_LEVEL—infois a good default for production.DEV=false— enables production behavior (secure cookies, no debug output).OWNER_EMAIL— the owner account email.JWT_SECRET— signs user session tokens. Rotating it invalidates existing sessions.DATA_ENCRYPTION_KEY— 32-character key for encrypting sensitive stored data. Generate withopenssl rand -hex 16(produces exactly 32 hex chars).SMTP_HOST/SMTP_USER/SMTP_PASS— email credentials. The example uses Resend's SMTP gateway (smtp.resend.com, userresend, password = API key). Without a working SMTP config the server runs fine, but sign-in emails are skipped — see below for a bootstrap workaround.MAIL_FROM— sender address. Must belong to a domain verified in your email provider.
ACME requirements: the domain's A/AAAA record must point at this server, and ports 80 and 443 must be open before you start the service. For a quick test without owning a domain, a wildcard DNS like <ip>.nip.io works.
Generate secrets before starting:
openssl rand -hex 32 # use as JWT_SECRET
openssl rand -hex 16 # use as DATA_ENCRYPTION_KEY (exactly 32 chars)
Create the systemd unit
Create /etc/systemd/system/trip2g.service:
[Unit]
Description=trip2g publishing server
After=network.target
[Service]
Type=simple
EnvironmentFile=/etc/trip2g.env
ExecStart=/usr/local/bin/trip2g
WorkingDirectory=/var/lib/trip2g
Restart=on-failure
[Install]
WantedBy=multi-user.target
The unit runs as root so the binary can bind ports 80 and 443.
Start and verify
systemctl daemon-reload
systemctl enable --now trip2g
Check the service is running:
systemctl is-active trip2g
journalctl -u trip2g -f
Verify HTTPS:
curl -I https://docs.example.com/
Expect HTTP/2 200 with a valid Let's Encrypt certificate.
After that, open https://docs.example.com, sign in with OWNER_EMAIL, and continue with Getting started.
If email is not configured yet: no sign-in email can be sent, so you can't get the code — set LOG_SIGN_IN_CODES=true in /etc/trip2g.env, restart (systemctl restart trip2g), trigger a sign-in, then read the code from journalctl -u trip2g. This prints codes in plain text, so unset the flag (or configure SMTP) once you're in.
Docker Compose (full stack)
trip2g + MinIO (S3-compatible object storage) + Caddy, all in one docker-compose.yml.
flowchart TD
Net[Internet] -->|443| Caddy[Caddy<br/>ports 80/443]
Caddy -->|docs.example.com| T[trip2g :8081]
Caddy -->|files.example.com| M[MinIO :9000]
T -->|S3 API| M
subgraph compose_network_internal
T
M
end
Easy-to-miss requirements
- A public server should use
HTTPS. Otherwise secure auth cookies will not work correctly. - Email sign-in needs a
resend.comaccount, an API key, and a verified sender domain or subdomain. - In production you must set your own
JWT_SECRETandDATA_ENCRYPTION_KEY. - In practice, only
80and443should be exposed externally bycaddy.
Prerequisites
You need:
- a Linux server with Docker and the Docker Compose plugin
- a site domain such as
docs.example.com - a sender subdomain such as
mg.example.com - DNS access
Create a directory such as /opt/trip2g and put two files there: docker-compose.yml and .env.
Check your server before setup
If the server is not fresh, verify the following before starting:
- Ports
80and443are free — compose hands them to Caddy:ss -tlnp | grep -E ':80 |:443 ' - No other Caddy, Nginx, or Traefik is already listening on those ports. If there is, stop it or move it to a different port.
- No conflicting Docker networks from other projects (rare, but can happen with non-default bridge or overlay setups).
If those ports are already claimed by an existing reverse proxy (Nginx, Caddy, Traefik), there is no need to remove it — just add trip2g as an upstream in your existing config, drop the caddy service from docker-compose.yml, and publish port 8081 directly. The same applies to MinIO: if you already have your own object storage, skip the minio service entirely and point .env at your existing bucket.
docker-compose.yml
services:
caddy:
image: caddy:2
restart: unless-stopped
depends_on:
trip2g:
condition: service_started
minio:
condition: service_healthy
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
volumes:
- minio-data:/data
expose:
- "9000"
- "9001"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 5s
timeout: 5s
retries: 20
trip2g:
image: ghcr.io/trip2g/trip2g:latest
restart: unless-stopped
depends_on:
minio:
condition: service_healthy
env_file:
- .env
volumes:
- trip2g-data:/data
expose:
- "8081"
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8082/healthz"]
interval: 10s
timeout: 5s
retries: 12
start_period: 15s
volumes:
caddy-data:
caddy-config:
trip2g-data:
minio-data:
Why these choices matter:
caddyis the only service exposing80and443.trip2g-datakeeps trip2g data and the internal bare git repository.minio-datakeeps MinIO objects.trip2gandminiostay internal to the compose network.
.env
Minimal production .env:
PUBLIC_URL=https://docs.example.com
LISTEN_ADDR=0.0.0.0:8081
INTERNAL_LISTEN_ADDR=:8082
DB_FILE=/data/data.sqlite3
GIT_API_REPO_PATH=/data/git
LOG_LEVEL=info
DEV=false
OWNER_EMAIL=owner@example.com
MAIL_FROM=no-reply@mg.example.com
RESEND_API_KEY=re_xxxxxxxxx
JWT_SECRET=replace-with-long-random-secret
DATA_ENCRYPTION_KEY=0123456789abcdef0123456789abcdef
MINIO_ROOT_USER=trip2g
MINIO_ROOT_PASSWORD=replace-with-long-random-password
MINIO_ENDPOINT=minio:9000
MINIO_PUBLIC_URL=https://files.example.com
MINIO_ACCESS_KEY_ID=trip2g
MINIO_SECRET_KEY=replace-with-long-random-password
MINIO_BUCKET=trip2g
MINIO_REGION=us-east-1
MINIO_USE_SSL=false
MINIO_INIT_TIMEOUT=30s
MINIO_URL_EXPIRES_IN=10m
SIMPLE_BACKUP=true
FEATURES={}
# OpenAI embeddings:
# OPENAI_API_KEY=sk-...
# FEATURES={"vector_search":{"enabled":true,"model":"text-embedding-3-small"}}
# OpenAI-compatible embeddings API:
# OPENAI_API_KEY=provider-token-if-needed
# FEATURES={"vector_search":{"enabled":true,"model":"bge-m3","base_url":"https://embeddings.example.com/v1"}}
Using a TRIP2G_ prefix
By default, trip2g reads configuration from plain env vars like LISTEN_ADDR or JWT_SECRET.
If you run trip2g alongside other services that share the same environment (e.g. a single .env file for multiple containers), you can prefix every trip2g var with TRIP2G_ to avoid name collisions:
TRIP2G_PUBLIC_URL=https://docs.example.com
TRIP2G_LISTEN_ADDR=0.0.0.0:8081
TRIP2G_JWT_SECRET=replace-with-long-random-secret
# … and so on for every setting
When a TRIP2G_ var is present it takes precedence over the plain counterpart. Any TRIP2G_ var that does not map to a known setting is logged as a warning on startup — useful for catching typos without crashing the server.
What each setting does
PUBLIC_URLis the external URL of your site. trip2g uses it for links, email flows, and integrations.LISTEN_ADDRis the main HTTP listen address.INTERNAL_LISTEN_ADDRis the internal address used for health checks and service endpoints.DB_FILEis the data file path inside the container.GIT_API_REPO_PATHis the path to trip2g's built-in git repository.LOG_LEVELsets server log verbosity.DEV=falseenables production behavior.OWNER_EMAILis the owner account email.MAIL_FROMis the sender address. It must belong to a domain verified in Resend.RESEND_API_KEYis used to send email sign-in codes.JWT_SECRETsigns user session tokens. Rotating it invalidates existing sessions.DATA_ENCRYPTION_KEYis a 32-byte key used to encrypt sensitive stored data. To generate one:openssl rand -base64 32 | head -c 32MINIO_ROOT_USER/MINIO_ROOT_PASSWORDare the MinIO root credentials.MINIO_ENDPOINTis the MinIO address as seen from thetrip2gcontainer.MINIO_PUBLIC_URLis the public MinIO hostname used in presigned file URLs.MINIO_ACCESS_KEY_ID/MINIO_SECRET_KEYare the credentials trip2g uses for MinIO.MINIO_BUCKETis the S3 bucket for assets and backup objects.MINIO_REGIONis the S3 region string. For MinIO,us-east-1is fine.MINIO_USE_SSL=falseis normal for container-to-container traffic on one host.MINIO_INIT_TIMEOUTcontrols how long startup waits for MinIO.MINIO_URL_EXPIRES_INcontrols presigned URL lifetime for file downloads.SIMPLE_BACKUP=trueenables simple SQLite backups to MinIO.FEATURESis the JSON feature-flag config. Vector search lives here.OPENAI_API_KEYis the key used for OpenAI or a compatible embeddings provider.
Optional HTTP-only smoke test:
PUBLIC_URL=http://SERVER_IP:8081
USER_TOKEN_INSECURE=true
Use that only for temporary testing. For a public instance, keep secure cookies and use TLS.
Background job schedules
Background jobs run on a schedule stored in the cron_jobs table; most run every minute by default. To change any job, set an env var named after the job — <JOB_NAME>_SCHEDULE — to a cron expression (6-field, with a leading seconds field):
# Run these hourly instead of every minute
EXECUTE_CRON_WEBHOOKS_SCHEDULE=0 0 * * * *
SEND_SCHEDULED_TELEGRAM_PUBLISHPOSTS_SCHEDULE=0 0 * * * *
The variable name is the job's name upper-cased plus _SCHEDULE. A longer interval means those jobs fire less precisely (up to an hour late) in exchange for less database load.
The managed (public cloud) instances run these hourly by default to keep the shared database light. Self-hosted instances keep the every-minute default unless you set the override.
Caddyfile
For a clean public setup, give the site and file storage separate hostnames:
docs.example.com→ trip2gfiles.example.com→ MinIO
Create a Caddyfile next to docker-compose.yml:
docs.example.com {
encode zstd gzip
reverse_proxy trip2g:8081
}
files.example.com {
encode zstd gzip
reverse_proxy minio:9000
}
# Optional, if you want the MinIO console externally:
# minio-admin.example.com {
# reverse_proxy minio:9001
# }
With that setup, these values in .env should match:
PUBLIC_URL=https://docs.example.com
MINIO_PUBLIC_URL=https://files.example.com
Why this matters:
- trip2g stays on the main site hostname;
- file URLs use a public MinIO hostname;
caddyreachestrip2gandminioby service name inside the docker network.
External object storage instead of MinIO
By default MinIO runs on the same server as trip2g. That is convenient to start but offers no protection against server loss: if the disk dies, files and backups go with it.
For production, we recommend moving storage to a separate S3-compatible service: Backblaze B2, Hetzner Object Storage, Timeweb S3, or similar.
In that case you can remove the minio service from docker-compose.yml entirely and point .env at the external service:
MINIO_ENDPOINT=s3.us-east-005.backblazeb2.com
MINIO_PUBLIC_URL=https://files.example.com
MINIO_ACCESS_KEY_ID=your-key-id
MINIO_SECRET_KEY=your-secret
MINIO_BUCKET=trip2g
MINIO_REGION=us-east-005
MINIO_USE_SSL=true
With that in place, SIMPLE_BACKUP=true stores SQLite backups on the external service automatically — no extra work, and protected from server-level failure.
SQLite replication with Litestream
SIMPLE_BACKUP=true takes periodic SQLite snapshots to MinIO. For continuous replication with a one-second interval, add Litestream.
Litestream runs on the host as a systemd service and streams the database file directly to any S3-compatible target. The infra/ directory already has a ready-made setup:
infra/generate-litestream-config.sh— generates/etc/litestream.ymlfrom environment variablesinfra/litestream.service— the systemd unit
The config reads the same variables as trip2g's .env: MINIO_ACCESS_KEY_ID, MINIO_SECRET_KEY, MINIO_ENDPOINT, MINIO_BUCKET, DB_FILE. After installing litestream:
sudo cp infra/generate-litestream-config.sh /usr/local/bin/generate-litestream-config.sh
sudo chmod +x /usr/local/bin/generate-litestream-config.sh
sudo cp infra/litestream.service /etc/systemd/system/litestream.service
sudo systemctl enable --now litestream
Litestream and SIMPLE_BACKUP can run together — they do not conflict. The combination is especially useful with external object storage: both files and the database then live outside the server.
Create a free Resend account
On resend.com:
- Create a free account.
- Add a sending domain, preferably a subdomain such as
mg.example.com. - Add the DNS records Resend asks for.
- Create an API key.
- Put that key into
RESEND_API_KEY. - Set
MAIL_FROMto an address inside the verified domain, for exampleno-reply@mg.example.com.
Why a subdomain is better:
- it isolates sender reputation;
- it keeps trip2g transactional mail separate from your main domain mail.
If you do not verify a sender domain in Resend, email delivery is effectively just for your own address. That is enough if only the owner signs in by email. If other users need email login, verify the sender domain.
Enable vector search with OpenAI or another compatible service
trip2g works fine without vector search. Full-text search still works.
If you want semantic search:
OpenAI
OPENAI_API_KEY=sk-...
FEATURES={"vector_search":{"enabled":true,"model":"text-embedding-3-small"}}
Recommended starting model: text-embedding-3-small.
OpenAI-compatible embeddings API
OPENAI_API_KEY=provider-token-if-needed
FEATURES={"vector_search":{"enabled":true,"model":"bge-m3","base_url":"https://embeddings.example.com/v1"}}
Important: trip2g validates the embedding model name. The currently supported values are:
text-embedding-3-smalltext-embedding-3-largetext-embedding-ada-002multilingual-e5-basebge-m3
So "any OpenAI-compatible service" only works if it exposes a compatible /v1 embeddings API and you configure one of those supported model names.
Start the stack
Inside the directory with docker-compose.yml:
docker compose up -d
If you still use the old syntax:
docker-compose up -d
Check the result:
docker compose ps
docker compose logs -f caddy trip2g
After startup:
- open
https://docs.example.com - sign in with the owner email from
OWNER_EMAIL - on an empty instance, the service itself will offer a link to download a preconfigured vault ZIP
- configure the Obsidian plugin with your
PUBLIC_URL
From there, continue with Getting started.
Things people often forget
A/AAAADNS record forPUBLIC_URLA/AAAADNS record forMINIO_PUBLIC_URL- TLS certificate for the site domain
- Resend DNS records for the sender domain
- persistent storage for
trip2g-data - do not publish the MinIO console on
9001unless you actually need it - checking logs after the first login and the first outbound email
For the smoothest rollout, start without vector search, verify email sign-in first, and only then enable embeddings in FEATURES.
fly.io
Deploy trip2g on fly.io without managing a server. See en/user/fly for the full guide.