Deploy QQueue on a VPS
Production Docker Compose with Caddy.
This is the production self-hosting path for one VPS running Docker Compose. Caddy serves the React dashboard, proxies the API, and manages HTTPS certificates automatically. The production stack also includes the API, worker, Postgres, Redis, and MinIO for attachment storage.
QQueue is beta software. Before exposing it to real users, complete this guide and then work through the Beta Launch Checklist.
What You Need
- A VPS with at least 2 GB RAM. More is recommended for real mail volume.
- Docker Engine and Docker Compose installed on the VPS.
- A domain or subdomain for QQueue, for example
mail.example.com. - A DNS A record pointing that domain to the VPS public IPv4 address.
- Firewall ports
80and443open to the internet. - Outbound SMTP allowed by your VPS provider, or an external SMTP provider.
- SMTP credentials for the mailbox/provider QQueue should send through.
You do not need Node.js, pnpm, Postgres, or Redis installed on the host for production. Docker Compose builds and runs the app containers.
In production, only Caddy publishes host ports 80 and 443. If another
process already uses either port, the caddy service will fail to start and
QQueue will not be reachable until you free those ports or put QQueue behind an
existing reverse proxy.
The bundled Postgres, Redis, API, worker, and MinIO services are private to
Docker. They are not published on host ports, so host services on 4000,
5432, 6379, 9000, or 9100 do not conflict with the production stack.
If you already run Nginx on the VPS, use the Nginx mode in this guide. It keeps
Nginx on public ports 80/443 and exposes QQueue only on
127.0.0.1:8080.
1. Clone the Repository
git clone https://github.com/your-org/qqueue.git
cd qqueueReplace the clone URL with the real repository URL for your deployment.
2. Configure Production Environment
The guided setup writes a production-ready .env for you — it generates all
secrets and passwords, sets your domain, and explains each value as it goes
(requires Node.js 20+ and pnpm on the server; pnpm install first):
pnpm install
pnpm setup -- --mode=production --domain=mail.example.comBringing your own hosted Postgres, Redis, or object storage instead of the
bundled containers? See Managed infrastructure
for how to get them (Neon, Upstash, R2) and which PROD_* overrides to set.
Copy the template first:
cp .env.example .envOpen .env and set the production values. At minimum, change these:
NODE_ENV=production
DOMAIN=mail.example.com
POSTGRES_USER=qqueue
POSTGRES_PASSWORD=replace-with-a-long-random-password
POSTGRES_DB=qqueue
# Leave blank to use the bundled private Postgres and Redis containers.
PROD_DATABASE_URL=
PROD_REDIS_HOST=
PROD_REDIS_PORT=
JWT_ACCESS_SECRET=replace-with-openssl-rand-hex-32
JWT_REFRESH_SECRET=replace-with-openssl-rand-hex-32
ENCRYPTION_KEY=replace-with-openssl-rand-hex-32
TRACKING_SECRET=replace-with-openssl-rand-hex-32
# Set this only if you want to accept provider bounce/complaint webhooks.
# Leave blank to keep POST /api/v1/webhooks/email-events closed.
WEBHOOK_SECRET=
# Bundled MinIO object storage for attachments.
S3_REGION=us-east-1
S3_BUCKET=qqueue-attachments
S3_ACCESS_KEY_ID=qqueue
S3_SECRET_ACCESS_KEY=replace-with-a-long-random-password
S3_FORCE_PATH_STYLE=true
MINIO_ROOT_PASSWORD=replace-with-the-same-value-as-S3_SECRET_ACCESS_KEYGenerate secrets with:
openssl rand -hex 32Important production notes (they apply on both routes):
DOMAINmust match the public hostname users open in the browser.- Production Compose derives
APP_URL,PUBLIC_APP_URL, andWEB_ORIGINfromDOMAIN, so you do not need to set those separately for the bundled stack. - Back up
ENCRYPTION_KEY. QQueue uses it to encrypt stored SMTP credentials. If you lose or rotate it after saving SMTP connections, those credentials cannot be decrypted and must be re-entered. - Keep
TRACKING_SECRETstable. Rotating it invalidates open/click links in already-sent emails. - Production Compose sets the internal bundled MinIO endpoint automatically.
- Use the same value for
S3_SECRET_ACCESS_KEYandMINIO_ROOT_PASSWORDwhen using bundled MinIO.
To use external infrastructure instead (step-by-step provider guides in Managed infrastructure):
- External Postgres: set
PROD_DATABASE_URL. - External Redis: set
PROD_REDIS_HOSTandPROD_REDIS_PORT, plusREDIS_PASSWORDandREDIS_TLS=trueif the provider requires them (Upstash does). - External S3/R2/B2/etc.: add
PROD_S3_ENDPOINTand set theS3_*variables for that provider. For AWS S3, setPROD_S3_ENDPOINT=andS3_FORCE_PATH_STYLE=false.
3. Confirm DNS and Firewall
Before starting Caddy, make sure DNS resolves to the VPS:
dig +short mail.example.comThe result should be the VPS public IP. Also confirm ports 80 and 443 are
open in your cloud firewall and any host firewall you use. Caddy cannot issue a
Let's Encrypt certificate until the domain points at the server and both ports
are reachable.
4. Start QQueue
Choose one of these modes.
Option A: Let QQueue Manage HTTPS
docker compose -f docker-compose.prod.yml up -d --buildUse this when no other service owns host ports 80 and 443. Caddy will bind
those ports and request certificates from Let's Encrypt.
Option B: Put QQueue Behind Existing Nginx
Use this when Nginx already owns host ports 80 and 443:
docker compose -f docker-compose.prod.yml -f docker-compose.nginx.yml up -d --buildThis starts QQueue on localhost only:
http://127.0.0.1:8080When using this mode, keep both -f flags on future up, restart, logs,
and ps commands. Running later commands with only docker-compose.prod.yml
will switch the caddy service back to public 80/443 bindings.
If you want a different local upstream port, set QQUEUE_UPSTREAM_PORT in
.env, for example:
QQUEUE_UPSTREAM_PORT=18080Then configure Nginx to terminate HTTPS and proxy to QQueue:
server {
listen 80;
server_name mail.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}For HTTPS, use your normal Nginx TLS/certbot setup and keep the same location
block in the 443 ssl server. Do not expose 127.0.0.1:8080 publicly.
Startup order:
- Postgres, Redis, and MinIO become healthy.
- The one-shot
migrateservice runsprisma migrate deploy. - The API and worker start.
- Caddy serves the dashboard and proxies
/api/*plus/health.
Check container status:
docker compose -f docker-compose.prod.yml psView logs:
docker compose -f docker-compose.prod.yml logs -fFor one service:
docker compose -f docker-compose.prod.yml logs -f api
docker compose -f docker-compose.prod.yml logs -f worker
docker compose -f docker-compose.prod.yml logs -f caddy5. Verify the Deployment
Open the dashboard:
https://mail.example.comCheck the health endpoint:
https://mail.example.com/healthExpected health response:
{ "status": "ok" }If HTTPS fails, check DNS, firewall rules, and Caddy logs. If you get 502 Bad Gateway, check that the migrate service completed and the API is running.
6. Complete the Setup Wizard
Open:
https://mail.example.comOn a fresh install QQueue routes you into a short setup wizard: it creates your administrator account and first organization, connects and verifies the sending account (SMTP), asks whether other people may register on this server (default: invite only — the safe choice for a server on the open internet), and optionally sends you a test email. Everything it configures can be changed later in Settings (the registration policy lives under Settings → Instance).
If you close the tab mid-wizard, sign in and visit /setup to resume.
7. Connect SMTP (reference)
The wizard already connected your first sending account; use this section when adding more. In the dashboard, go to Sending accounts (the SMTP connections screen) and create a connection for the mailbox or provider you want QQueue to send through.
For a standard submission server, common settings are:
- Host: your SMTP hostname, for example
smtp.example.com. - Port:
587. - Secure/TLS: off for STARTTLS on
587, on for implicit TLS on465. - Username/password: the SMTP credentials for the sending mailbox.
- From email/from name: the sender identity recipients should see.
- Default: enabled if this should be the organization's default sender.
QQueue verifies credentials before saving and stores them encrypted using
ENCRYPTION_KEY.
If you run Mailcow, use the Mailcow SMTP setup. If the SMTP test fails, check whether your VPS provider blocks outbound SMTP and test from the server with:
nc -vz smtp.example.com 5878. Send and Track a Test Email
After SMTP is connected:
- Send a manual test email from the dashboard.
- Confirm the message arrives in the recipient inbox.
- Open the message and click a link if you included one.
- Check the dashboard activity/analytics pages for send, open, and click events.
- Check Queue Operations for failed or retriable jobs.
Open/click tracking links resolve through:
https://mail.example.com/api/v1/track/...That means DOMAIN must remain publicly reachable over HTTPS.
Email Provider Webhooks
Synchronous SMTP rejections are recorded automatically when the SMTP server rejects a recipient during send.
For asynchronous bounces and complaints, configure your provider to call:
POST https://mail.example.com/api/v1/webhooks/email-eventsInclude:
X-Webhook-Secret: <WEBHOOK_SECRET>Use this JSON shape:
{ "type": "BOUNCED", "messageId": "<provider-message-id>", "reason": "..." }type is one of DELIVERED, BOUNCED, or COMPLAINED. The event is
correlated by messageId or emailJobId. Most providers need a small relay or
function to map their webhook payload to this shape. Leave WEBHOOK_SECRET
blank if you are not using this endpoint.
Backups
At minimum, back up:
- The Postgres database.
- The
.envfile, especiallyENCRYPTION_KEY. - The
qqueue-minio-dataDocker volume if you use bundled MinIO for attachments.
Create a Postgres dump:
docker compose -f docker-compose.prod.yml exec -T postgres \
pg_dump -U qqueue qqueue > qqueue-$(date +%F).sqlIf you changed POSTGRES_USER or POSTGRES_DB, use those values in the command.
Store backups off the VPS and test restores before relying on them.
Updating QQueue
Before updating, take a database backup. Then:
git pull
docker compose -f docker-compose.prod.yml up -d --buildThe migrate service runs on every deploy and applies pending Prisma
migrations before the API and worker start.
After updating:
docker compose -f docker-compose.prod.yml psThen re-check:
https://mail.example.com/healthCommon Operations
Restart everything:
docker compose -f docker-compose.prod.yml restartRestart one service:
docker compose -f docker-compose.prod.yml restart apiStop the stack without deleting data:
docker compose -f docker-compose.prod.yml downShow disk usage for Docker resources:
docker system dfTroubleshooting
- HTTPS or proxy failures: see Caddy / reverse proxy issues.
- SMTP failures: see SMTP connection failures.
- Queue or worker failures: see Failed queue jobs.
- Prisma or migration failures: see Prisma migration issues.
When asking for help, include:
- The command that failed.
- Relevant logs from
docker compose -f docker-compose.prod.yml logs <service>. - The output of
docker compose -f docker-compose.prod.yml ps. - Any job
failedReasonshown in Queue Operations.