This guide covers enabling HTTPS for your Torrust Tracker deployment using automatic TLS certificates.
The deployer includes Caddy as an automatic TLS reverse proxy. When you enable HTTPS for any service, Caddy:
- Automatically obtains and renews TLS certificates from Let's Encrypt
- Handles HTTPS termination (services run HTTP internally)
- Redirects HTTP to HTTPS automatically
- Supports HTTP/2 and HTTP/3
Before enabling HTTPS, ensure you have:
- Domain names - Valid domain names pointing to your server's IP address
- DNS configured - A records for each domain pointing to your server
- Ports 80 and 443 open - Required for Let's Encrypt certificate validation
- Public IP - Your server must be reachable from the internet
Note: For local testing with
.localdomains, Caddy uses its internal CA. Certificates will show browser warnings but work correctly.
Add an https section to your environment configuration:
{
"https": {
"admin_email": "admin@example.com",
"use_staging": false
}
}Configuration Fields:
| Field | Required | Default | Description |
|---|---|---|---|
admin_email |
Yes (if any service uses TLS) | - | Email for Let's Encrypt notifications |
use_staging |
No | false |
Use Let's Encrypt staging (for testing) |
Each service that supports HTTPS has two fields:
domain- The domain name for certificate acquisitionuse_tls_proxy- Whether to enable HTTPS via Caddy
Both fields are required to enable HTTPS for a service.
{
"tracker": {
"http_api": {
"bind_address": "0.0.0.0:1212",
"admin_token": "MySecureToken",
"domain": "api.tracker.example.com",
"use_tls_proxy": true
}
}
}Each HTTP tracker can independently have HTTPS enabled:
{
"tracker": {
"http_trackers": [
{
"bind_address": "0.0.0.0:7070",
"domain": "http1.tracker.example.com",
"use_tls_proxy": true
},
{
"bind_address": "0.0.0.0:7071",
"domain": "http2.tracker.example.com",
"use_tls_proxy": true
},
{
"bind_address": "0.0.0.0:7072"
}
]
}
}In this example, the first two trackers use HTTPS while the third uses HTTP only.
{
"tracker": {
"health_check_api": {
"bind_address": "0.0.0.0:1313",
"domain": "health.tracker.example.com",
"use_tls_proxy": true
}
}
}{
"grafana": {
"admin_user": "admin",
"admin_password": "SecurePassword123!",
"domain": "grafana.example.com",
"use_tls_proxy": true
}
}| Environment | CA URL | Rate Limits | Browser Trust |
|---|---|---|---|
| Production (default) | acme-v02.api.letsencrypt.org |
50 certs/week, 5 duplicates/week | ✅ Trusted |
| Staging | acme-staging-v02.api.letsencrypt.org |
Much higher | ❌ Shows warnings |
Use staging for:
- Initial testing and development
- CI/CD environments
- Verifying configuration before production
{
"https": {
"admin_email": "admin@example.com",
"use_staging": true
}
}Production Let's Encrypt has these rate limits:
- 50 certificates per week per registered domain
- 5 duplicate certificates per week per domain set
- 300 pending authorizations per account
- 5 failed validations per hostname per hour
Tip: Always test with
"use_staging": truefirst to avoid hitting rate limits.
Caddy automatically renews certificates:
- Renewal attempts begin 30 days before expiry
- Renewal happens daily at random times to distribute load
- No manual intervention required
- Admin email receives warnings if renewal fails
Production deployment with HTTPS for all services:
{
"environment": {
"name": "production"
},
"ssh_credentials": {
"private_key_path": "~/.ssh/id_rsa",
"public_key_path": "~/.ssh/id_rsa.pub"
},
"provider": {
"provider": "lxd",
"profile_name": "torrust-profile-prod"
},
"https": {
"admin_email": "admin@example.com"
},
"tracker": {
"http_api": {
"bind_address": "0.0.0.0:1212",
"admin_token": "MySecureToken",
"domain": "api.tracker.example.com",
"use_tls_proxy": true
},
"http_trackers": [
{
"bind_address": "0.0.0.0:7070",
"domain": "http.tracker.example.com",
"use_tls_proxy": true
}
]
},
"grafana": {
"admin_user": "admin",
"admin_password": "SecurePassword123!",
"domain": "grafana.example.com",
"use_tls_proxy": true
},
"prometheus": {
"scrape_interval_in_secs": 15
}
}Secure only the API (contains sensitive admin token), other services use HTTP:
{
"https": {
"admin_email": "admin@example.com"
},
"tracker": {
"http_api": {
"bind_address": "0.0.0.0:1212",
"admin_token": "MySecureToken",
"domain": "api.tracker.example.com",
"use_tls_proxy": true
},
"http_trackers": [
{
"bind_address": "0.0.0.0:7070"
}
]
},
"grafana": {
"admin_user": "admin",
"admin_password": "SecurePassword123!"
}
}For local development using LXD with self-signed certificates:
{
"https": {
"admin_email": "admin@tracker.local",
"use_staging": true
},
"tracker": {
"http_api": {
"bind_address": "0.0.0.0:1212",
"admin_token": "MyAccessToken",
"domain": "api.tracker.local",
"use_tls_proxy": true
},
"http_trackers": [
{
"bind_address": "0.0.0.0:7070",
"domain": "http1.tracker.local",
"use_tls_proxy": true
}
]
},
"grafana": {
"admin_user": "admin",
"admin_password": "admin-password",
"domain": "grafana.tracker.local",
"use_tls_proxy": true
}
}Important: Add entries to
/etc/hostson your machine to resolve.localdomains:<VM_IP> api.tracker.local <VM_IP> http1.tracker.local <VM_IP> grafana.tracker.local
To deploy without HTTPS, simply omit the https section and domain/use_tls_proxy fields:
{
"tracker": {
"http_api": {
"bind_address": "0.0.0.0:1212",
"admin_token": "MyAccessToken"
},
"http_trackers": [
{
"bind_address": "0.0.0.0:7070"
}
]
}
}To disable HTTPS for a service, either:
- Remove both fields - Omit
domainanduse_tls_proxy - Set
use_tls_proxy: false- Keep domain but disable TLS
If no services use HTTPS, you can also remove the https section entirely.
When HTTPS is enabled for a service:
| Service | Without TLS | With TLS |
|---|---|---|
| Tracker API | Port exposed (e.g., 1212) | Port hidden, accessed via Caddy (443) |
| HTTP Tracker | Port exposed (e.g., 7070) | Port hidden, accessed via Caddy (443) |
| Grafana | Port 3000 exposed | Port hidden, accessed via Caddy (443) |
Security benefit: Backend service ports are not exposed when TLS is enabled, reducing attack surface.
After deployment, verify HTTPS is working:
# Get VM IP from environment using the show command
torrust-tracker-deployer show <env-name>
# Look for "Instance IP" in the output and set it:
INSTANCE_IP=<your-vm-ip>
# Test HTTPS endpoint (replace domain with your actual domain)
curl -v --resolve api.tracker.example.com:443:$INSTANCE_IP https://api.tracker.example.com/api/health_checkcurl -I --resolve api.tracker.example.com:80:$INSTANCE_IP http://api.tracker.example.com/
# Should return: HTTP/1.1 308 Permanent Redirectssh -i <key> torrust@$INSTANCE_IP "docker logs caddy --tail 20"| Endpoint | Expected Response | Notes |
|---|---|---|
| Tracker API | HTTP 500 (Unauthorized) | Auth required, but TLS works |
| HTTP Tracker | HTTP 404 | GET not supported, but TLS works |
| Grafana | HTTP 302 (Redirect to /login) | Login page loads |
Symptoms: Caddy logs show ACME errors, browser shows certificate warnings.
Solutions:
- Check DNS - Ensure domain points to your server's IP
- Check ports - Ports 80 and 443 must be open and reachable
- Check rate limits - Try staging mode first
- Check domain - Must be a valid, publicly resolvable domain
Symptoms: curl: (7) Failed to connect
Solutions:
- Check Caddy is running:
docker ps | grep caddy - Check firewall: Ports 80, 443 must be open
- Check logs:
docker logs caddy
For .local domains: This is expected. Caddy uses its internal CA for domains that can't be validated via Let's Encrypt.
For real domains: Check that DNS is configured correctly and the domain is publicly reachable.
| Error | Cause | Solution |
|---|---|---|
TlsProxyWithoutDomain |
use_tls_proxy: true without domain |
Add domain field |
InvalidDomain |
Invalid domain format | Check domain syntax |
InvalidAdminEmail |
Invalid email format | Check email syntax |
HttpsRequiresTlsService |
https section without any TLS services |
Add use_tls_proxy: true to at least one service |
TlsRequiresHttpsSection |
TLS service without https section |
Add https section with admin_email |
Internet → Port 443 → Caddy (TLS termination) → HTTP → Service Container
↑
Reverse proxy by domain
- Caddy receives HTTPS requests on port 443
- Terminates TLS using Let's Encrypt certificates
- Proxies to backend via internal Docker network (HTTP)
- Returns response over the encrypted connection
- All services run in the same Docker network (
torrust-network) - Caddy accesses backend services by container name (e.g.,
tracker:1212) - Backend ports are only exposed to Caddy, not to the host (when TLS enabled)
Caddy stores certificates in Docker volumes:
caddy_data- Certificates and private keyscaddy_config- Caddy configuration cache
These volumes persist across container restarts, preventing unnecessary certificate reissuance.
- Security Guide - Firewall and security configuration
- Grafana Service - Grafana-specific configuration
- Prometheus Service - Prometheus-specific configuration
- Template Customization - Advanced template options