Deploying NATS JetStream on a VPS: Docker, TLS, Authentication, and Data Persistence
TL;DR
NATS JetStream can be reliably deployed on a VPS in Docker: the NATS server will accept messages over TLS, restrict access with a username and password, and retain stream data on disk between container restarts.
- We use Ubuntu Server 24.04 LTS, Docker Engine, and the official
nats:2.11-alpineimage. - JetStream stores messages in a persistent Docker volume on a separate disk.
- A TLS certificate for NATS is issued through Certbot and Let’s Encrypt.
- We publicly expose the NATS port
4222, while keeping monitoring on localhost and publishing it through Caddy over HTTPS. - Access to NATS is protected with environment variables, while configuration and data are regularly copied using Restic to S3-compatible storage.
- Before updating, we check the JetStream status, create a backup, and use a maintenance window.
1. TL;DR
NATS is a fast message broker with low resource consumption. JetStream adds persistent messages, delivery acknowledgments, redelivery, and consumer groups. As a result, a VPS can be used as a standalone event bus for microservices, background jobs, integrations, and realtime applications.
This article uses a single server with Docker Compose. This is a good option for development, a small production project, or internal infrastructure, but it is not a fully fault-tolerant clustered configuration. For critical data, backups and recommendations for migrating to a cluster are also provided below.
2. Contents
The installation is divided into sequential stages: selecting resources, basic security hardening, installing Docker, issuing a certificate, launching NATS, checking JetStream, configuring backups, and maintenance. The commands are intended for a clean server running Ubuntu Server 24.04 LTS with a domain name.
3. What We Are Configuring and Why
NATS and JetStream
NATS uses a simple publish/subscribe messaging protocol. A client publishes a message to a subject, and subscribers receive it with almost no delay. Basic NATS is memory-oriented and is not designed for long-term event storage after a subscriber disconnects.
JetStream adds persistent storage. A stream accepts messages from one or more subjects and writes them to disk. A consumer defines how an application reads the stream: where to start, whether acknowledgment is required, how many times delivery should be retried, and how messages should be handled after a failure.
| Component | Purpose | Example |
|---|---|---|
| NATS server | Message routing between clients | Publishing order events |
| JetStream | Message persistence and redelivery | Background job queue |
| Stream | Message log mapped to a subject | events.orders |
| Consumer | Read and acknowledgment state | Payment processing worker |
| Monitoring endpoint | Metrics and diagnostic JSON endpoints | /jsz, /varz |
What Will Be Ready at the End
After following the instructions, a NATS container with JetStream and file storage will be running. Clients will be able to connect to an address such as tls://nats.example.com:4222. A username and password will be required to connect, and the server will validate the domain certificate.
NATS monitoring should not be exposed to the entire internet: it contains information about connections, subjects, consumers, and server status. Therefore, port 8222 will listen only on the loopback interface. If remote access is needed, Caddy will publish it over HTTPS with a separate domain and basic HTTP authentication.
Self-hosted or Cloud NATS
Cloud NATS typically provides ready-made clusters, automatic updates, redundancy, and certificate management. This reduces the operational burden, but the cost grows with traffic, the number of connections, and storage volume. In addition, data and network routing are controlled by an external operator.
Self-hosted NATS on a VPS provides full control over configuration, region, disk, network rules, and backups. The cost of this freedom is self-managed maintenance: updating the OS, monitoring disk usage, checking certificates, testing recovery, and responding to failures.
| Criterion | Self-hosted on a VPS | Cloud-managed |
|---|---|---|
| Control | Full access to the OS and data | Depends on the provider |
| Fault tolerance | Must be designed independently | Often included in the plan |
| Cost | Predictable VPS and disk cost | Depends on traffic and storage |
| Maintenance | Your responsibility | Some tasks are handled by the operator |
A single VPS does not replace a cluster. If server loss is unacceptable, at least three NATS nodes with JetStream replication, separate failure domains, and backups are required. For an initial production deployment with moderate load, a single node is acceptable if the recovery procedure has been verified.
4. What VPS Configuration Is Needed for This Task
Resources depend not so much on the number of messages as on payload size, retention period, number of connections, number of consumers, and disk write patterns. NATS itself is lightweight, but JetStream actively uses disk and the kernel page cache.
Minimum Configuration
- CPU: 2 virtual cores.
- RAM: 2 GB for a small test or internal service.
- Disk: 40–60 GB SSD/NVMe, preferably with guaranteed IOPS.
- Network: 1 Gbit/s port or higher; stable latency matters more than peak speed.
- OS: Ubuntu Server 24.04 LTS or another supported Linux distribution.
- IPv4: a dedicated public IPv4 is recommended for DNS and clients.
- IPv6: useful but not required if clients use IPv4.
The minimum option is suitable for several dozen connections, small messages, and storage within a few gigabytes. Do not fill the disk beyond 80%. JetStream needs free space for new segments, temporary files, and safe maintenance.
Practical Configuration for Small Production
For a team or SaaS application, it is reasonable to start with 4 vCPU, 8 GB RAM, and 100–160 GB NVMe. Such a server provides headroom for Docker, monitoring, backup archives, and short-term spikes. Choose disk capacity using the following formula: daily message volume multiplied by the retention period, plus 30–50% free space.
For example, if an application publishes 15 GB of messages per day and retains them for seven days, the stream alone will use about 105 GB, excluding service data. For this scenario, a 160 GB disk is too small: it is better to choose 250–300 GB or limit retention and move the archive to object storage.
As one option, you can choose a VPS with these specifications: from 4 vCPU, 8 GB RAM, an NVMe disk from 100 GB, and a public IPv4. The specific plan must be checked against the actual message volume, IOPS, and network traffic.
When a Dedicated Server Is Needed
A dedicated server makes sense for sustained high disk write activity, a large number of connections, strict latency requirements, or the need to fully control CPU and I/O. On a noisy-neighbor VPS, disk performance can vary, which is noticeable with frequent fsync operations and large JetStream streams.
A dedicated server is also more convenient for a cluster of multiple NATS nodes if each node requires fast local NVMe. However, one powerful dedicated server does not provide high availability on its own. For fault tolerance, multiple independent nodes are more important than one server with a large number of cores.
Choosing a Location
Place NATS closer to the applications that publish and consume messages. Latency between a service and the broker affects acknowledgment time, request/reply operation, and queue processing speed. If the main clients are in one region, choose the same region or a nearby one with low RTT.
For international clients, a single node in a remote region can result in unstable latency. In this case, use regional NATS clusters, leaf nodes, or separate brokers for different zones. Check not only ping, but also the actual TCP/TLS handshake and message publish time.
5. Server Preparation
The following assumes a clean Ubuntu Server 24.04 LTS installation. Run commands as a user with temporary root privileges. Do not disconnect the current SSH session until you verify login with the new user in a separate window.
Creating an Administrator and SSH Key
First, create a separate user. The example uses the name deploy; replace it with your own. Password-based SSH login will be disabled later.
# Create a user for administration
sudo adduser deploy
# Add the user to the sudo group
sudo usermod -aG sudo deploy
# Create a directory for the public SSH key
sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
# Copy the current user's public key to the deploy directory
sudo cp ~/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys
# Fix the owner and permissions of the key file
sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keys
If the key has not yet been created on the workstation, run locally:
# Create a modern Ed25519 key pair
ssh-keygen -t ed25519 -a 100 -C "nats-admin"
# Copy the public key to the server
ssh-copy-id deploy@SERVER_IP
Verify login:
# Open a new SSH session and make sure sudo works
ssh deploy@SERVER_IP
sudo -v
Basic SSH Configuration
Create a separate drop-in file instead of editing the main configuration unless necessary. The port number 2222 below is an example. If you change the SSH port, allow it in the firewall first.
# Create SSH settings to disable root access and passwords
sudo tee /etc/ssh/sshd_config.d/ hardening.conf >/dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
EOF
# Check SSH configuration syntax
sudo sshd -t
# Apply settings without disconnecting active sessions
sudo systemctl reload ssh
In the actual command, the file name must not contain a space after the directory. Correct creation command:
sudo tee /etc/ssh/sshd_config.d/hardening.conf >/dev/null <<'EOF'
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
MaxAuthTries 3
EOF
System Updates and Basic Packages
# Update package indexes and install security updates
sudo apt update && sudo apt full-upgrade -y
# Install administration tools and automatic updates
sudo apt install -y \
ca-certificates curl gnupg jq unzip vim htop lsof \
ufw fail2ban unattended-upgrades apt-transport-https
# Enable automatic installation of security updates
sudo dpkg-reconfigure -plow unattended-upgrades
Firewall and fail2ban
Allow SSH before enabling UFW, otherwise access may be lost. Port 80 is required by Certbot for HTTP validation and certificate renewal, 443 is for HTTPS monitoring through Caddy, and 4222 is for NATS clients.
# Allow SSH, HTTP, HTTPS, and the NATS TLS port
sudo ufw allow OpenSSH
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow 4222/tcp
# Enable the firewall with a deny policy for incoming connections
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw --force enable
# Check active rules
sudo ufw status verbose
The monitoring port 8222 is intentionally not exposed. It will be bound to 127.0.0.1 in the NATS configuration. Fail2ban can be enabled to protect SSH:
# Start fail2ban and enable it at boot
sudo systemctl enable --now fail2ban
# Check SSH protection status
sudo fail2ban-client status sshd
6. Software Installation — Step by Step
In 2026, this scenario uses Docker Engine from the official Docker repository, the Compose Plugin, and the NATS 2.11.x image. The exact patch release may change, so in production it is advisable to pin a specific digest after testing.
Step 1. Installing Docker Engine
# Remove potentially conflicting old packages
sudo apt remove -y docker.io docker-doc docker-compose podman-docker containerd runc || true
# Create a directory for repository keys
sudo install -m 0755 -d /etc/apt/keyrings
# Download the official Docker key
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
-o /etc/apt/keyrings/docker.asc
# Make the key readable by the package manager
sudo chmod a+r /etc/apt/keyrings/docker.asc
# Add the official Docker repository for Ubuntu 24.04
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/ubuntu noble stable" | \
sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
# Update the index and install Docker Engine and the Compose Plugin
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
To avoid using sudo before every Docker command, add the user to the group. The group provides near-root access, so add only trusted administrators.
# Allow the deploy user to manage Docker
sudo usermod -aG docker deploy
# Restart the SSH session, then verify Docker
docker run --rm hello-world
# Check versions of installed software
docker version
docker compose version
Step 2. Creating Project Directories
# Create directories for configuration, certificates, and backups
sudo install -d -m 750 /opt/nats/{config,certs,backups,caddy}
# Transfer ownership of the project directory to the deploy user
sudo chown -R deploy:deploy /opt/nats
# Change to the working directory
cd /opt/nats
Step 3. Installing Certbot and Issuing a Certificate
A domain certificate is required for NATS TLS connections. Let the domain be nats.example.com, with its DNS A record already pointing to the server's IPv4 address. Replace the domain and email with your own values.
# Install Certbot from the Ubuntu repository
sudo apt install -y certbot
# Temporarily stop the service that may occupy port 80
sudo systemctl stop caddy 2>/dev/null || true
# Issue a certificate using the HTTP-01 challenge
sudo certbot certonly --standalone \
--preferred-challenges http \
-d nats.example.com \
--email [email protected] \
--agree-tos \
--no-eff-email
Let’s Encrypt certificates are renewed every 90 days. Certbot usually installs a systemd timer. You can check it as follows:
# Check automatic certificate renewal
sudo systemctl list-timers 'certbot'
# Perform a test dry-run without issuing a new certificate
sudo certbot renew --dry-run
Step 4. Creating the NATS User and Secrets
Passwords should not be written directly into configuration files, repositories, or the command line, where they may end up in shell history. Generate URL-safe values and save them in a file with 600 permissions.
# Generate random passwords without characters problematic for YAML and URLs
NATS_USER_PASSWORD=$(openssl rand -base64 36 | tr -dc 'A-Za-z0-9' | head -c 32)
NATS_ADMIN_PASSWORD=$(openssl rand -base64 36 | tr -dc 'A-Za-z0-9' | head -c 32)
CADDY_PASSWORD=$(openssl rand -base64 36 | tr -dc 'A-Za-z0-9' | head -c 32)
# Write secrets to a local env file
cat > /opt/nats/.env <<EOF
NATS_APP_USER=app
NATS_APP_PASSWORD=${NATS_USER_PASSWORD}
NATS_ADMIN_USER=admin
NATS_ADMIN_PASSWORD=${NATS_ADMIN_PASSWORD}
CADDY_USER=monitor
CADDY_PASSWORD=${CADDY_PASSWORD}
EOF
# Prevent other users from reading the file
chmod 600 /opt/nats/.env
# Display only variable names, not the secrets themselves
cut -d= -f1 /opt/nats/.env
Step 5. Obtaining the Official NATS Image
# Download the official NATS image from the 2.11 Alpine branch
docker pull nats:2.11-alpine
# Check that the image is available locally
docker image ls nats
For production, after verification, you can replace the floating tag with a specific patch tag or digest. This prevents unexpected behavior changes during the next docker compose pull.
7. Configuration
NATS Configuration
Create the file /opt/nats/config/nats.conf. Values in quotes containing $NATS_APP_PASSWORD constructs will be substituted from the container environment. JetStream stores data in the /data/jetstream directory, which will be mounted to a persistent volume.
cat > /opt/nats/config/nats.conf <<'EOF'
server_name: nats-vps-1
port: 4222
http: 127.0.0.1:8222
jetstream {
store_dir: /data/jetstream
max_mem_store: 512MB
max_file_store: 80GB
}
tls {
cert_file: /etc/nats/certs/fullchain.pem
key_file: /etc/nats/certs/privkey.pem
verify: false
timeout: 2
}
authorization {
users = [
{
user: "$NATS_APP_USER"
password: "$NATS_APP_PASSWORD"
permissions: {
publish: {
allow: ["events.>", "jobs.>"]
}
subscribe: {
allow: ["events.>", "jobs.>", "_INBOX.>"]
}
}
}
{
user: "$NATS_ADMIN_USER"
password: "$NATS_ADMIN_PASSWORD"
permissions: {
publish: [">"]
subscribe: [">"]
}
}
]
}
max_payload: 1MB
max_pending: 64MB
write_deadline: 2s
EOF
# Restrict configuration read access
chmod 640 /opt/nats/config/nats.conf
The max_file_store parameter is the upper limit of JetStream file storage, not the size of an automatically allocated disk. The value must be less than the available space on the VPS. If the disk fills up, publishing and stream servicing will start failing.
In production, it is better to use separate users with minimal permissions for each application. The administrator should not be used in application code. In the example, the app user can work only with the events.>, jobs.> subjects and inbox addresses.
Certificate Permissions
Docker must be able to access the Let’s Encrypt certificates. The /etc/letsencrypt/live folder contains symbolic links, so the entire /etc/letsencrypt directory must be mounted in read-only mode.
# Verify that the certificate was issued for the required domain
sudo certbot certificates
# Ensure that certificate files are accessible to root
sudo ls -l /etc/letsencrypt/live/nats.example.com/
Docker Compose
Create the file /opt/nats/compose.yaml. The container does not expose the monitoring port externally: the 127.0.0.1:8222:8222 entry restricts access to the server loopback address.
cat > /opt/nats/compose.yaml <<'EOF'
services:
nats:
image: nats:2.11-alpine
container_name: nats
restart: unless-stopped
command: ["-c", "/etc/nats/nats.conf"]
env_file:
- .env
ports:
- "4222:4222"
- "127.0.0.1:8222:8222"
volumes:
- nats_data:/data
- ./config/nats.conf:/etc/nats/nats.conf:ro
- /etc/letsencrypt:/etc/nats/certs:ro
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8222/healthz"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
stop_grace_period: 30s
logging:
driver: json-file
options:
max-size: "20m"
max-file: "5"
volumes:
nats_data:
name: nats_jetstream_data
EOF
# Check the resulting Compose configuration with substituted variables
docker compose config
# Start NATS in the background
docker compose up -d
# Check container status
docker compose ps
After startup, check the logs. An error indicating that privkey.pem cannot be read usually means there is a problem with the certificate mount or access permissions. An error about an unknown configuration parameter indicates syntax incompatibility with the NATS version.
# View the last 100 lines of the NATS log
docker compose logs --tail=100 nats
# Check the health endpoint on loopback
curl -fsS http://127.0.0.1:8222/healthz
# Get server status in JSON
curl -fsS http://127.0.0.1:8222/varz | jq '{server_id, server_name, version, jetstream}'
HTTPS for Monitoring via Caddy
NATS clients use TLS directly on port 4222. Caddy is needed not to proxy the NATS protocol, but to securely expose HTTP monitoring. To do this, install Caddy from the official repository or your OS package, then create the configuration.
# Install Caddy from the official APT repository
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | \
sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | \
sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy
Create a DNS record for nats-monitor.example.com pointing to the same server and create the file /etc/caddy/Caddyfile. The password hash can be obtained with the caddy hash-password command.
# Generate a bcrypt password hash for basic authentication
sudo caddy hash-password --plaintext 'REPLACE_WITH_MONITOR_PASSWORD'
nats-monitor.example.com {
basicauth {
monitor REPLACE_WITH_BCRYPT_HASH
}
reverse_proxy 127.0.0.1:8222
}
# Check the Caddy configuration
sudo caddy validate --config /etc/caddy/Caddyfile
# Start Caddy and enable automatic startup
sudo systemctl enable --now caddy
# Check HTTPS monitoring
curl -u monitor:'REPLACE_WITH_MONITOR_PASSWORD' \
https://nats-monitor.example.com/healthz
Caddy automatically obtains an HTTPS certificate for the monitoring domain. Do not mix this certificate with the NATS certificate unless you have verified that clients connect using the correct name. For NATS, the certificate must contain the name specified in the client URL, for example nats.example.com.
Testing TLS and Client Connectivity
You can check the certificate using OpenSSL. The command should complete without a chain verification error if DNS and the certificate are configured correctly.
# Check the TLS handshake and NATS certificate name
openssl s_client \
-connect nats.example.com:4222 \
-servername nats.example.com \
-verify_return_error </dev/null
For functional testing, install the NATS CLI from the official NATS CLI release. It is best to pin the version according to the project release notes; below is a check of an already installed command.
# Verify that NATS CLI is installed
nats --version
# Create a connection profile with TLS and application credentials
nats context add vps \
--server "tls://nats.example.com:4222" \
--user "$NATS_APP_USER" \
--password "$NATS_APP_PASSWORD"
# Select the profile
nats context select vps
# Check the server connection
nats server check connection
# Check JetStream status
nats account info
If the variables are not loaded in the current shell session, read them from the file only in an interactive administrative session:
# Load variables from the protected file into the current shell session
set -a
. /opt/nats/.env
set +a
# Repeat the connection check
nats account info
Creating a Stream and Consumer
The example creates an order events stream with file storage and a retention period of seven days. For a task queue, use a separate stream and a different retention policy. Do not place unrelated message types in a single stream unless necessary: this complicates limits and cleanup.
# Create a stream for order events with disk storage
nats stream add ORDERS \
--subjects "events.orders.>" \
--storage file \
--retention limits \
--max-age 168h \
--max-bytes 50GB \
--replicas 1 \
--discard old \
--ack
# Display stream configuration
nats stream info ORDERS
# Create a durable consumer with manual acknowledgment
nats consumer add ORDERS order-workers \
--filter "events.orders.>" \
--ack explicit \
--wait 30s \
--max-deliver 5 \
--max-pending 1000 \
--pull
# Publish a test message
nats pub events.orders.created '{"order_id":"test-001","status":"created"}'
# Read one message through the consumer
nats consumer next ORDERS order-workers
In production, set the maximum message size at both the application and server levels. It is better to store large payloads in object storage and send the file identifier, event type, and metadata through NATS. This reduces disk load and speeds up replication.
8. Backups and Maintenance
What Needs to Be Saved
The backup must include the NATS configuration, the Compose file, secrets, the certificate strategy, and JetStream data. A container copy alone is not sufficient: the container can be recreated, but the volume containing messages and the consumer state must not be lost.
/opt/nats/config/nats.conf— server configuration./opt/nats/compose.yaml— launch method./opt/nats/.env— secrets stored encrypted in the backup.- Docker volume
nats_jetstream_data— JetStream file data. - Stream and consumer configurations — they should be exported separately through the CLI.
- Information about DNS, domains, and the certificate issuance procedure.
Do not copy live JetStream files with a simple rsync during intensive writes, and do not consider such a snapshot guaranteed to be consistent. For a small node, it is more reliable to stop NATS for a short window, copy the data, and then start it again. To minimize downtime, use provider disk snapshots if they are created atomically.
Exporting the JetStream Configuration
# Create a directory for stream and consumer descriptions
mkdir -p /opt/nats/backups/js-config
# Save the list of streams in text format
nats stream ls > /opt/nats/backups/js-config/streams.txt
# Save the stream configuration in JSON
nats stream info ORDERS --json \
> /opt/nats/backups/js-config/orders.json
# Save the consumer configuration in JSON
nats consumer info ORDERS order-workers --json \
> /opt/nats/backups/js-config/order-workers.json
Restic and S3-Compatible Storage
It is best to store the backup outside the VPS: in S3-compatible object storage or on a separate server in another zone. Restic encrypts data before sending it, so the storage provider cannot see the archive contents. Restic and S3 passwords must be accessible only to root.
# Install Restic from the Ubuntu repository
sudo apt install -y restic
# Create a file of variables for backups
sudo tee /root/.restic-env >/dev/null <<'EOF'
export RESTIC_REPOSITORY="s3:https://s3.example.net/nats-backup"
export AWS_ACCESS_KEY_ID="REPLACE_ACCESS_KEY"
export AWS_SECRET_ACCESS_KEY="REPLACE_SECRET_KEY"
export RESTIC_PASSWORD="REPLACE_LONG_RANDOM_REPOSITORY_PASSWORD"
EOF
# Restrict access to the secrets
sudo chmod 600 /root/.restic-env
# Initialize the encrypted Restic repository
sudo bash -c '. /root/.restic-env; restic init'
Create a script. It stops the container, archives the Docker volume and configuration, and then starts NATS. The broker is unavailable during the operation, so choose a window with minimal traffic.
sudo tee /usr/local/sbin/backup-nats.sh >/dev/null <<'EOF'
#!/usr/bin/env bash
set -Eeuo pipefail
. /root/.restic-env
WORKDIR="/var/backups/nats-staging"
mkdir -p "$WORKDIR"
rm -rf "$WORKDIR"/
cd /opt/nats
docker compose stop nats
docker run --rm \
-v nats_jetstream_data:/source:ro \
-v "$WORKDIR":/backup \
alpine:3.21 \
sh -c 'cd /source && tar czf /backup/jetstream-data.tar.gz .'
tar czf "$WORKDIR/nats-config.tar.gz" \
/opt/nats/config \
/opt/nats/compose.yaml \
/opt/nats/.env
restic backup "$WORKDIR" --tag nats-vps
restic forget --tag nats-vps --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
docker compose up -d
rm -rf "$WORKDIR"
EOF
sudo chmod 700 /usr/local/sbin/backup-nats.sh
# Run the backup manually and check the result
sudo /usr/local/sbin/backup-nats.sh
# Check the list of Restic snapshots
sudo bash -c '. /root/.restic-env; restic snapshots'
The script above saves the volume archive after stopping NATS. Verify that the container has actually returned to the Up state and that the healthcheck succeeds:
# Check the container after the backup
docker compose -f /opt/nats/compose.yaml ps
# Check JetStream and the health endpoint
curl -fsS http://127.0.0.1:8222/healthz
nats account info
cron Scheduler
# Run the backup every night at 03:30 server time
sudo tee /etc/cron.d/nats-backup >/dev/null <<'EOF'
30 3 * root /usr/local/sbin/backup-nats.sh >> /var/log/nats-backup.log 2>&1
EOF
sudo chmod 644 /etc/cron.d/nats-backup
A backup is considered valid only after a test restoration. Periodically create a temporary directory, download the latest snapshot, and verify that the configuration and data can be extracted from the archive. For critical systems, separately test starting NATS on a new VPS.
NATS and OS Updates
Updating a single NATS node is not a rolling update: the container will be restarted, and clients will lose their connections. Before updating, create a backup, check the release notes, verify client library compatibility, and prepare a maintenance window.
# Save the current image version and state
docker image inspect nats:2.11-alpine --format '{{.RepoDigests}}'
docker compose -f /opt/nats/compose.yaml ps
nats account info
# Pull the updated image after checking the release notes
docker compose -f /opt/nats/compose.yaml pull nats
# Recreate only the NATS container
docker compose -f /opt/nats/compose.yaml up -d nats
# Check the logs and healthcheck after the update
docker compose -f /opt/nats/compose.yaml logs --tail=100 nats
curl -fsS http://127.0.0.1:8222/healthz
For a cluster of three or more nodes, updates can be performed one node at a time while monitoring quorum and JetStream status. For a single server, a scheduled maintenance window is safer. Do not remove the old Docker volume or run docker compose down -v unless you intend to delete the data.
Observability and Disk Monitoring
# Check free space on the filesystem
df -h
# Check the size of Docker volumes
docker system df -v
# Check JetStream metrics and active consumers
curl -fsS http://127.0.0.1:8222/jsz?consumers=true | jq
# View memory and CPU usage by the container
docker stats --no-stream nats
Configure an alert for disk usage reaching 70–80%, a growing number of pending messages, and an increasing number of redeliveries. For more serious deployments, connect the Prometheus endpoint and Grafana, but do not expose diagnostic endpoints without authentication and network restrictions.
9. Troubleshooting and FAQ
Why does the client receive a TLS handshake or certificate verify failed error?
Make sure the client connects to a domain name present in the certificate rather than to an IP address. Run openssl s_client -connect domain:4222 -servername domain and check the DNS A record, certificate validity period, and the fullchain.pem chain. Make sure Certbot did not issue a certificate for another domain. In the client, specify the TLS URL and the system trusted CA store; certificate verification should not be disabled in production.
The NATS container exits immediately after startup. What should be checked?
Start with docker compose logs nats. Common causes include an incorrect path to nats.conf, a syntax error, a missing certificate, or an inability to read an environment variable. Run docker compose config to see the resulting configuration, and check that the files exist in /etc/letsencrypt/live/домен/. After fixing the issue, run docker compose up -d and examine the logs again.
Why does JetStream not preserve data after recreating the container?
Verify that the nats_jetstream_data volume is being used rather than a temporary bind mount inside the container. The command docker volume inspect nats_jetstream_data will show its location and name. Do not run docker compose down -v: the -v flag removes the volume. Also make sure that store_dir: /data/jetstream is set in the configuration and that the /data directory is connected to a persistent volume.
A “permissions violation” error occurs when publishing a message. What should be done?
This means that the user does not have permission to publish or subscribe to the selected subject. Match the application's subject with the user's rules in the authorization section. The app user from the example is permitted only for events.>, jobs.>, and _INBOX.>. After changing the configuration, recreate the container and reconnect: permissions are applied when the client session is established.
What is the minimum suitable VPS configuration?
For testing and a small internal system, 2 vCPU, 2 GB of RAM, and an SSD of 40–60 GB or more are sufficient. For a small production deployment, it is better to choose 4 vCPU, 8 GB of RAM, and a 100 GB or larger NVMe drive, especially if messages are stored for several days. Stable IOPS, free space, and a public IPv4 address are important. If the data volume is growing quickly, first increase the disk size and configure retention rather than simply adding CPU.
Which should be chosen — VPS or dedicated for this task?
A VPS is suitable for most small NATS JetStream installations: it is cheaper, easier to scale vertically, and sufficient under moderate write loads. A dedicated server is justified for consistently high disk load, large streams, strict latency requirements, or the need to eliminate the influence of neighboring virtual machines. However, a dedicated server does not automatically solve the high-availability problem. It requires multiple nodes, replication, and backups.
Why is the number of pending messages for a consumer growing?
The consumer receives messages more slowly than they are published. Check worker CPU and memory, external API latency, the number of parallel handlers, and the max_ack_pending parameter. View the information with the command nats consumer info ORDERS order-workers. Increasing the limit without analysis is risky: it can lead to increased memory usage and redeliveries. Usually, the correct solution is to add workers, use queue-like processing, and configure backoff.
What happens if the disk runs out of space?
JetStream will stop accepting new messages normally, while Docker and the OS itself may begin to operate unstably. Immediately check df -h, stream sizes, and the NATS logs. Remove unnecessary Docker images and logs, but do not delete the JetStream volume manually. Then reduce retention, the maximum stream size, or expand the disk. After recovery, check the consumer and republish a test message.
Can port 8222 be opened for remote monitoring?
It should not be opened to the entire internet: the monitoring API exposes diagnostic information and is not a full access-control system. Keep the port on 127.0.0.1, and use Caddy with HTTPS and basic authentication, a VPN, or an SSH tunnel for remote access. For temporary viewing, run ssh -L 8222:127.0.0.1:8222 deploy@server, then open http://127.0.0.1:8222 locally.
10. Conclusions and Next Steps
NATS JetStream has been deployed on a VPS with Docker, TLS on the client port, authorization, persistent storage, and HTTPS monitoring. The configuration separates application and administrator access, while Restic makes it possible to store encrypted backups outside the server.
Next, configure monitoring of disk usage and consumer lag, regularly verify backup restoration, and pin Docker image versions. As the load grows, move to multiple NATS nodes with JetStream replication, separate failure domains, and a rolling update plan.
- Define retention for each stream based on the actual message volume.
- Add Prometheus/Grafana or another controlled metrics collection system.
- Test restoration on a temporary server before it is needed during an incident.