Skip to main content

How to self-host

Overview

Vantik is one Docker Compose stack in the main repository. It runs the webapp, the API server, and all the services: PostgreSQL, Redis, SuperTokens, and Typesense. The server applies the database migrations when it starts. You need no separate repository for the deployment, and no setup script.

This guide gives you a deployment that works. You control the other work: the scale of the deployment, the backups, and the security beyond the basic level.

Requirements

  • Docker, or Podman with the compose provider. A Podman VM needs 8 GB of memory or more to build the images.
  • 4 CPUs and 8 GB of RAM is a comfortable minimum.

Instructions

  1. Clone the repository and make your .env file:
git clone https://github.com/cmunte132/vantik.git
cd vantik
cp .env.example .env
  1. For a deployment that is more than a test on localhost, edit .env:

    • Set FRONTEND_HOST and BACKEND_HOST to your domain.
    • Change POSTGRES_PASSWORD, TYPESENSE_API_KEY, and TRIGGER_TOKEN.
  2. Start the stack:

docker compose up -d
  1. Open the webapp and sign in with your email address. The default address is http://localhost:3000, or use your FRONTEND_HOST. If you configure no SMTP server, the server writes the magic login link to its log:
docker compose logs server | grep -A5 "magic link"

How to sign in

There are two methods, and a new installation has both.

An email code, or a magic link. Type an address. Vantik then sends you an email with a code of six digits and a link. This method needs SMTP. Without SMTP, the server writes the code to its log.

A passkey. Authenticate with Touch ID, Windows Hello, or a security key. You need no mail server, so this is the practical method for one user, or for a local installation with no SMTP. Sign in with a passkey from the login screen. You can also add a passkey to an account that exists, under Settings → Security.

A passkey is bound to the hostname that the browser sees, and that name comes from FRONTEND_HOST. If you open the same instance on a different domain, the browser does not offer the passkeys of the old domain. Therefore set FRONTEND_HOST before people enrol. WebAuthn also needs a secure context: use HTTPS in production. localhost is an exception, and it needs no TLS.

Each account holds one passkey. The email code always continues to work, so the loss of a device does not lock you out. To move a passkey to a new device, remove the old passkey under Settings → Security, and enrol the new one.

By default, any person can make an account with a passkey. For an instance that accepts an invitation only, disable that:

PASSKEY_SIGNUP_ENABLED=false

A person then arrives with an invitation or a login code, and adds a passkey later from Settings.

Scheduled jobs

Vantik does some of its work on a schedule, and not in response to a request. The server process runs this work as Bull repeatable jobs, on the Redis that the stack already needs. No necessary work depends on an optional service. The server registers each job when it starts, and it writes the schedule to the log. To see if a job runs, read docker compose logs server.

JobDefaultVariableWhat it does
Cycle maintenancehourlyCYCLE_MAINTENANCE_CRONThis job applies to a team with the automatic cadence. It completes each cycle after the end date of that cycle. It then moves the unfinished issues, as the preference of the team tells it to, and it makes more future cycles. The job never changes a team that controls its cycles manually.
Knowledge decay0 3 * * *PAGE_DECAY_CRONThis job archives each knowledge entry that no person triaged and that the server never served.

To stop a job, set its variable to off.

Optional services

Every service below is optional. If you configure no service, the server writes an error to the log and continues.

Email (SMTP)

With SMTP, the server sends the magic login links and the notification emails. Without SMTP, the login links go to the server log. A passkey needs no email at all, so an installation for one user can leave SMTP unset. Any provider with SMTP authentication works, such as Gmail, Amazon SES, or SendGrid:

SMTP_HOST=your-smtp-host
SMTP_PORT=587
SMTP_USER=your-smtp-username
SMTP_PASSWORD=your-smtp-password

AI features

The AI features talk to one endpoint with the OpenAI interface. These are the features: the AI writes a title, it makes a description longer, it divides an issue into sub-issues, and it offers a filter. You choose the endpoint. It can be a hosted service or your own hardware, and you set it in one place:

LLM_BASE_URL=https://openrouter.ai/api/v1
LLM_API_KEY=your-key

# Vantik asks for a role, and never for the name of a model. Point each role
# where you want. The ids are the ids that your endpoint serves.
LLM_MODEL_FAST=openai/gpt-4o-mini
LLM_MODEL_SMART=openai/gpt-4o

fast does the short, frequent calls: the titles, the labels, and the filters. smart does the calls with a higher value: the descriptions and the sub-issues. You can point both roles at the same model.

People use these four setups:

SetupLLM_BASE_URLLLM_API_KEYNotes
OpenRouterhttps://openrouter.ai/api/v1your OpenRouter keyOne key, and most models. An id has a namespace, for example anthropic/claude-sonnet-4.5.
OpenAIhttps://api.openai.com/v1your OpenAI keyAn id has no namespace, for example gpt-4o-mini.
LM Studiohttp://localhost:1234/v1any valueNo data leaves the machine. Use the id from the server tab of LM Studio.
Ollamahttp://localhost:11434/v1any valueNo data leaves the machine, for example gemma2:2b. Pull the model before you point a role at it.

Any other endpoint with the same API works on the same four variables. For example: vLLM, LiteLLM, or a gateway in your company.

A local server ignores the key, but it still needs the header. For a local server, therefore, set LLM_API_KEY to any text that is not empty.

note

If you leave these variables unset, the interface has no AI in it: no button to write a title, no AI filter, and no AI entry in the slash menu of the editor. The interface shows no control that cannot work. If you set the variables later, the features appear on the next page load, and you rebuild nothing. The browser reads this configuration at runtime from /api/v1/config.

The other features do not change. You still create an issue with no title, but the AI writes no title for you. The issue suggestions still return an assignee from the search, but they suggest no label.

One request can still reach an AI endpoint: a direct API call, or an action that you deployed against an older server. That request fails with an error, and the error names the variable that you did not set. The server does not answer with a model that nobody chose.

Two more settings are optional, and only OpenRouter reads them. They record the use of your installation on the OpenRouter dashboard and on its public leaderboards:

LLM_APP_URL=https://vantik.example.com
LLM_APP_NAME=Vantik

The search needs no API key and no external service. Typesense makes the embeddings in its own process, with its ts/all-MiniLM-L12-v2 model. The issue titles, the descriptions, and the comments therefore never leave your deployment.

To build the search index again, delete the Typesense issues collection and restart the server. The server then makes the collection again, and it indexes every workspace from Postgres:

curl -X DELETE \
-H "X-TYPESENSE-API-KEY: $TYPESENSE_API_KEY" \
http://localhost:8108/collections/issues

The server does the same work automatically after an upgrade that adds a field to the search schema. It finds the old collection, deletes it, and fills the new one.

File attachments

The attachments of an issue go to a disk that the server mounts. This is the default, and it needs no configuration. docker compose up gives you a working upload immediately.

STORAGE_PROVIDER selects the backend:

ValueWhere the files go
localA disk that the server mounts. This is the default.
s3Amazon S3, or any store that answers the S3 protocol.

An empty value means local. If you give a name that the server does not have, the server stops at startup and tells you. If you select a backend but you do not give it the settings it needs, the server also stops at startup and names the settings. The server does not start and then fail on the first file.

Files on a disk

# STORAGE_PROVIDER=local is the default. These settings are optional.
LOCAL_STORAGE_PATH=/data/attachments
PUBLIC_ATTACHMENT_URL=https://your-domain.com/api

The compose file mounts the volume attachment-data at /data/attachments. Keep that volume in your backup. If you remove it, you remove the files.

The server signs the URL of each attachment, and the signature expires. The server makes the secret that signs them on first start, and keeps it beside the files. Set ATTACHMENT_URL_SECRET yourself in two cases: more than one server shares the volume, or you want to hold the secret.

Object storage

One backend speaks to Amazon S3 and to every store that answers the same protocol. S3_ENDPOINT points it at a store other than Amazon.

# Amazon S3
STORAGE_PROVIDER=s3
BUCKET_NAME=your-bucket-name
AWS_REGION=your-region
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
PUBLIC_ATTACHMENT_URL=https://your-domain.com/api

Leave the key and the secret empty on a host that already has credentials, for example a host with an instance role. The AWS SDK finds them.

For a store that is not Amazon, add S3_ENDPOINT:

StoreS3_ENDPOINTAWS_REGION
Cloudflare R2https://<account-id>.r2.cloudflarestorage.comauto
MinIOhttp://minio:9000any value
Google Cloud Storagehttps://storage.googleapis.comauto
DigitalOcean Spaceshttps://<region>.digitaloceanspaces.comyour region
Backblaze B2https://s3.<region>.backblazeb2.comyour region

S3_FORCE_PATH_STYLE puts the name of the bucket in the path and not in the host name. It becomes true by itself when you set S3_ENDPOINT, because most stores that are not Amazon need it. Set it only if your store needs the other behaviour.

To try object storage before you pay for it, start the MinIO in the compose file:

docker compose --profile s3 up -d minio minio-bucket

Then set STORAGE_PROVIDER=s3, S3_ENDPOINT=http://minio:9000, BUCKET_NAME=vantik-attachments, and the user and password of MinIO as the access key and the secret.

If you used the GCP backend before

The server no longer has a Google Cloud Storage backend of its own. Google Cloud Storage still works, through the S3 backend and the interoperability endpoint. Your files stay where they are.

  1. Make an HMAC key for the service account that owns the bucket. In the Cloud Console, open Cloud Storage > Settings > Interoperability.
  2. Change your .env:
STORAGE_PROVIDER=s3
S3_ENDPOINT=https://storage.googleapis.com
AWS_REGION=auto
AWS_ACCESS_KEY_ID=your-hmac-key
AWS_SECRET_ACCESS_KEY=your-hmac-secret
BUCKET_NAME=your-bucket-name # no change
  1. Remove GCP_SERVICE_ACCOUNT_FILE. It has no effect.

A server that still has STORAGE_PROVIDER=gcp stops at startup and prints these steps.

CORS

A bucket needs a CORS configuration that accepts the origin of your frontend. The local backend needs no CORS configuration, because the server sends the files itself.

[
{
"AllowedHeaders": ["*"],
"AllowedMethods": ["GET", "PUT", "POST", "DELETE", "HEAD"],
"AllowedOrigins": ["https://your-domain.com"],
"ExposeHeaders": ["ETag"],
"MaxAgeSeconds": 3000
}
]

Background jobs

The integrations and the user notifications run inside the server, on the redis that the stack already needs. There is no separate service to deploy and nothing to configure: if the server runs, they run.

This was not always true. Until recently both went to trigger.dev, which is optional and is not in the compose file, so on a default deployment a notification was never delivered and an integration never fired. That is fixed, and trigger.dev is no longer part of Vantik.

Observability

Logs

The server writes JSON to stdout, one object for each line, at the level in LOG_LEVEL. Collect it with the method that your platform uses for container logs. You configure nothing.

Each line holds the correlation fields of the request: reqId, wId, aId, and opName. reqId comes from the x-request-id header. If the caller sends no such header, the server makes the value, and it returns the value on the response. wId is the workspace, aId is the user who acts, and opName is the endpoint. When you enable the traces, each line also holds traceId and spanId. From any log line you therefore go to its trace.

OTEL_EXPORTER_OTLP_ENDPOINT also exports every line over OTLP, with the same correlation fields as record attributes. This setting adds a destination, and it replaces none. The server writes to stdout in both cases, so your usual collection of the container logs continues to work, and it needs no configuration. You run no log shipper, and you mount no container socket.

:::caution Upgrade The old logs were coloured text on the console for a person to read. The server wrote JSON to a file only when CREATE_LOG_FILE=true. The logs are now always JSON on stdout, and CREATE_LOG_FILE is no longer present. The server wrote that file inside the container, where nothing read it. You must update any tool that parses the old console output. :::

Traces, metrics, and log export

All three are off by default, and no data leaves your deployment before you enable them. Point the server at any collector for OTLP over HTTP:

OTEL_EXPORTER_OTLP_ENDPOINT=http://your-collector:4318
OTEL_EXPORTER_OTLP_HEADERS=x-api-key=... # if your collector needs authentication

One switch controls all three signals, and that is the intent. A metrics pipeline that nothing collects is machinery that you cannot see. Therefore, with no endpoint, the server never starts the SDK: no instrumentation, no exporters, and no measurable cost. The log export is the one part that adds a destination. The server writes to stdout in both cases.

Traces. Each request makes one trace. The trace covers the HTTP handler, the Nest controller, and the calls below them to Postgres, Redis, and Prisma. You therefore see the query that makes an endpoint slow. The trace also records an unhandled 5xx exception on the span, with its stack.

Metrics. The rate, the errors, and the latency of the requests come from http.server.request.duration. Its labels are the route template, the method, and the status code. The outbound calls to Typesense, SuperTokens, and the LLM provider come from http.client.request.duration, with the host as the label. The metrics for the Node runtime cover the delay of the event loop, from the mean to p99. They also cover the use of the event loop, the duration of the garbage collection for each type of collector, and the use of the V8 heap for each space. An event loop that stops is the usual shape of a problem in a Node process. The request data alone does not show this problem, because the slow request and the request that blocks it are rarely the same request.

:::note The Prisma queries are in the traces, and not in the metrics db.client.operation.duration exists, but it comes from the instrumentation of the pg driver. That driver sees only the node-postgres client of the replication engine. Prisma reaches Postgres through its own Rust engine, and that engine emits spans but no metrics. There is therefore no metric for the rate or the latency of an application query. That data is on the prisma:engine:db_query spans inside the trace of each request. :::

The metric labels do not hold the ids of the workspace, the user, or the request, and that is the intent. Those ids live on the trace attributes, where one value for each request is the purpose. On a metric, each different value is another time series, and a label for each tenant is how a metrics backend fails. The server pushes the metrics every 60 seconds by default, and OTEL_METRIC_EXPORT_INTERVAL controls this period. The metrics are cumulative, so a longer period costs you resolution, and not data.

Any OpenTelemetry backend works: Grafana Tempo, Grafana Mimir, Honeycomb, Jaeger, Sentry, or a local collector that sends the data to several backends.

If you set an endpoint and no data arrives, set OTEL_LOG_LEVEL=debug. The exporter then shows its own diagnostics, which are silent at the other levels.

A local Grafana

You do not need your own backend to read this data. An overlay file gives you Grafana, Prometheus, Tempo, and an OTel collector in one container. The datasources are connected, and a Vantik dashboard is ready:

docker compose -f docker-compose.yaml -f docker-compose.observability.yaml up -d

Grafana is on http://localhost:3002, with anonymous admin access. For that reason, every port of the overlay is bound to 127.0.0.1. The overlay sets OTEL_EXPORTER_OTLP_ENDPOINT on the server for you. It also lowers OTEL_METRIC_EXPORT_INTERVAL to 5 seconds, because the default of 60 seconds makes you wait one minute to see a panel move when you test an endpoint by hand.

The Vantik → Vantik Server dashboard covers the rate, the errors, and the latency of the requests, for the whole server and for each route. It also covers the latency of the outbound call to each dependency, and the health of the Node runtime. The traces are in Explore → Tempo. A search for {span.http.route="/some/route"} takes you from a slow route to the Prisma and Redis calls below it.

The logs arrive in Loki over the same connection. A slow trace and its log lines are therefore two clicks apart, in both directions. No service reads the containers to make this work. See Logs above.

Do not use this overlay in production. The upstream image is for development and for a demonstration. It has no authentication, no policy for retention, and all four components in one container. For a real deployment, point OTEL_EXPORTER_OTLP_ENDPOINT at your own collector. Nothing in the app changes.

Health endpoints

EndpointMeaning
GET /Liveness. The process is up. This call is cheap, and it touches no dependency.
GET /health/readyReadiness. The endpoint returns 200 when Postgres, Redis, and Typesense all answer. If one does not answer, it returns 503, and the body gives the status of each dependency.

Point the liveness probe of your orchestrator at /, and its readiness probe at /health/ready. Do not use the readiness endpoint for liveness. A short fault in the database then kills the container and restarts it, and that action corrects nothing.

FAQ

Q: Is there a hosted cloud version? A: No. You host Vantik yourself.

Q: Which storage providers can hold the attachments? A: A disk that the server mounts, which is the default and needs no configuration. Also Amazon S3 and every store that answers the S3 protocol: Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2, Wasabi, and Google Cloud Storage through its interoperability endpoint. Read File attachments.

If this page does not answer your question, open an issue on GitHub.