Skip to main content
Open Source Solutions

Portainer + NPM + Vaultwarden: My Default Self-Hosted Stack

How I deploy Portainer, Nginx Proxy Manager, and Vaultwarden together: the Docker stack, the gotchas, and the operational rules I'd tattoo on a junior engineer.

Published Updated 11 min read

I run Portainer, Nginx Proxy Manager, and Vaultwarden as the default management plane on almost every Docker host I deploy for clients. Portainer is the GUI I open when I want to look at what containers are doing, Nginx Proxy Manager owns ports 80 and 443, and Vaultwarden is where the secrets the rest of the stack depends on actually live. Three containers, three jobs, one VPS.

This post is the deployment recipe I keep handing junior engineers when they ask why I do it this way. It covers the install order, the compose files, the operational rules I have learned the hard way, and the parts I have stopped trying to do “properly” because the simple version keeps working.

If you are starting from a fresh server, harden it first. The walkthrough at Linux server security fundamentals is the baseline I assume below. Skip it and you will rebuild this stack inside a week.

What each tool is actually for

Portainer is a web UI for Docker. It shows you running containers, lets you exec into them, surfaces logs, and gives you a place to deploy stacks without writing a systemd unit. It is not a configuration system and it is not a state machine. Treat it as a read-mostly dashboard with edit affordances, and it earns its keep on every box.

Nginx Proxy Manager (NPM) is a small Node.js app wrapped around Nginx and certbot. You point a domain at the server, click through a form to add a proxy host, and it writes the Nginx config and provisions a Let’s Encrypt certificate. It also handles renewals on a cron without ever asking you. The web UI is on port 81; public traffic uses 80 and 443.

Vaultwarden is a Bitwarden-compatible server written in Rust. The official Bitwarden self-hosted image needs around a gigabyte of RAM and a separate database container. Vaultwarden runs the same API in roughly 50MB. The official iOS, Android, and browser clients all talk to it without knowing the difference.

Together they form a tight loop: Portainer to operate the box, NPM to publish services with TLS, and Vaultwarden to hold every credential the operator needs to do either of those jobs.

Prerequisites

You need a VPS with at least 2GB of RAM. The whole stack idles around 500MB; the extra gigabyte is for the day NPM is reloading certificates while Watchtower pulls a new Vaultwarden image. A 1GB box technically runs all three, but it leaves no headroom for the next thing you want to deploy on the same host.

You also need DNS access for the domain you will use, and ideally an account with a DNS provider that supports API-driven Let’s Encrypt challenges (Cloudflare is the default). And you need a hardened server. If ufw is wide open and root SSH is enabled, do not put any of this on the box yet.

Docker engine installation

Install Docker from the official repository. Skip the convenience scripts, skip the Ubuntu universe package. The official repo is the only correct source.

sudo apt-get update
sudo apt-get install \
    ca-certificates \
    curl \
    gnupg \
    lsb-release
sudo mkdir -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update && sudo apt-get install docker-ce docker-ce-cli containerd.io docker-compose-plugin -y

Test the engine with the canonical hello-world image:

sudo docker run hello-world

Add your non-root user to the docker group so you do not have to sudo every command:

sudo usermod -aG docker username

Replace username with the actual login. Reboot once so the group change applies cleanly across SSH sessions and the Docker daemon:

sudo reboot

Installing Portainer

Portainer ships a Community Edition image (portainer/portainer-ce) that covers everything an agency-sized team needs. Create the data volume first, then start the container with three published ports and the Docker socket mounted in.

docker volume create portainer_data
docker run -d \
  -p 8000:8000 \
  -p 9000:9000 \
  -p 9443:9443 \
  --name portainer \
  --restart=always \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v portainer_data:/data \
  portainer/portainer-ce:latest

Visit http://your-server-ip:9000 in a browser. You have a five-minute window after first launch to set the admin password. Miss it and Portainer locks itself for security; you will need to restart the container to get the window back.

The HTTPS port 9443 is there in case you want direct TLS on Portainer without the proxy in front. I almost never use it: NPM does TLS better, and exposing 9443 on the public IP is one more port for ufw to keep track of.

Adding remote Docker hosts to Portainer

If you have a second Docker host you want to manage from the same Portainer (a VPN gateway, a separate database box, anything), install the Portainer Agent on the remote host:

docker run -d \
  -p 9001:9001 \
  --name portainer_agent \
  --restart=always \
  -v /var/run/docker.sock:/var/run/docker.sock \
  -v /var/lib/docker/volumes:/var/lib/docker/volumes \
  portainer/agent:latest

Then in the central Portainer UI, add an Environment, point it at tcp://remote-host:9001, and the agent shows up in the tree. Communication is over an authenticated channel, but I still firewall port 9001 to only the Portainer server’s IP. The agent is convenient; treat the convenience as a privileged channel and lock it down.

Installing Nginx Proxy Manager

NPM goes in next, before Vaultwarden, because Vaultwarden needs to be reachable on a TLS-protected hostname from day one. Drop this compose file into Portainer as a new Stack, or save it on disk as npm/docker-compose.yml and run docker compose up -d.

version: '3'
services:
  app:
    image: 'jc21/nginx-proxy-manager:latest'
    restart: unless-stopped
    ports:
      - '80:80'
      - '81:81'
      - '443:443'
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt
    labels:
      - "com.centurylinklabs.watchtower.enable=true"

Once it is up, visit http://your-server-ip:81. The default credentials are admin@example.com / changeme. Change them on the first screen; that prompt only appears once and there is no recovery path if you skip it.

Add your first proxy host: domain name on the left (portainer.yourdomain.com), forward to http://portainer:9000 (the container hostname, not the public IP), tick “Block Common Exploits”, and on the SSL tab request a Let’s Encrypt certificate with “Force SSL” and “HTTP/2 Support” enabled. NPM does the rest.

A second rule, learned the same way: do not expose port 81 (the NPM admin UI) to the public internet. Whitelist your office IP at the firewall level, or put it behind a self-hosted VPN. The Mistborn VPN platform and Wireguard Easy walkthroughs both cover the gateway pattern I use.

Installing Vaultwarden

Vaultwarden is the most security-sensitive of the three because it stores every credential your team uses to operate the rest of the platform. The compose file below is what I deploy verbatim, with the empty fields filled in from a password manager I keep specifically for bootstrapping new servers.

version: '3'

services:
  vaultwarden:
    restart: always
    container_name: vaultwarden
    image: vaultwarden/server:latest
    volumes:
      - ./vaultwarden/data:/data/
    ports:
      - 8062:80
    environment:
      - SMTP_HOST=
      - SMTP_FROM=
      - SMTP_FROM_NAME=
      - SMTP_SECURITY=starttls
      - SMTP_PORT=587
      - SMTP_USERNAME=
      - SMTP_PASSWORD=
      - SMTP_TIMEOUT=30
      - SMTP_AUTH_MECHANISM="TLS"
      - LOGIN_RATELIMIT_MAX_BURST=10
      - LOGIN_RATELIMIT_SECONDS=60
      - DOMAIN=
      - INVITATION_ORG_NAME=
      - INVITATIONS_ALLOWED=false
      - ADMIN_TOKEN=
      - SIGNUPS_ALLOWED=true
      - SIGNUPS_VERIFY=true
      - SIGNUPS_VERIFY_RESEND_TIME=3600
      - SIGNUPS_VERIFY_RESEND_LIMIT=6
      - EMERGENCY_ACCESS_ALLOWED=true
      - SENDS_ALLOWED=true
      - USE_SYSLOG=true
      - EXTENDED_LOGGING=true
      - WEB_VAULT_ENABLED=true
      - TIME_ZONE="Europe/Bratislava"
    labels:
      - "com.centurylinklabs.watchtower.enable=true"

A few of those environment variables matter more than the others.

DOMAIN is the full HTTPS URL Vaultwarden should believe it lives at, including the scheme. Get this wrong and the WebSocket subscription for live vault updates fails silently; the desktop client still works, but every change requires a manual refresh on the browser extension.

ADMIN_TOKEN should be a long, random string. The admin panel at /admin lets you read every user record, reset master passwords, and inspect organization data. Treat the token as if it were the database root password, because operationally it is.

SIGNUPS_ALLOWED=true plus SIGNUPS_VERIFY=true is the bootstrap pattern. Sign yourself in as the first user, verify your email, then go back to the admin panel and switch SIGNUPS_ALLOWED to false. After that, new accounts only get created via the admin invitation flow, and the public attack surface drops back down to “guess the master password of an existing user”.

The SMTP block is non-optional in production. Without working email, account verification, password reset, and invitation links all silently fail. I use a transactional provider (Postmark, Mailgun, or SES) for the same reason I use one with Uptime Kuma: your domain’s regular mail server is fine for newsletters and terrible for anything time-sensitive.

Once the container is up, add a proxy host in NPM that points vault.yourdomain.com to http://vaultwarden:80 (note: Vaultwarden inside the container listens on 80, even though we publish it as 8062). Tick “Websockets Support” on the proxy host’s Details tab; without it, the live vault sync over wss:// will not work.

Watchtower for image updates

The fourth container in this stack is technically optional, but I run it everywhere. Watchtower polls Docker Hub on a schedule, pulls new image tags for any container labelled com.centurylinklabs.watchtower.enable=true, and recreates the container with the new image.

version: '3'

services:
  watchtower:
    image: containrrr/watchtower:latest
    restart: always
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock
      - /etc/timezone:/etc/timezone:ro
    environment:
      - WATCHTOWER_CLEANUP=true
      - WATCHTOWER_LABEL_ENABLE=true
      - WATCHTOWER_NOTIFICATIONS=email
      - WATCHTOWER_NOTIFICATION_EMAIL_FROM=
      - WATCHTOWER_NOTIFICATION_EMAIL_TO=
      - WATCHTOWER_NOTIFICATION_EMAIL_SERVER=smtp.postmarkapp.com
      - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PORT=587
      - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_USER=
      - WATCHTOWER_NOTIFICATION_EMAIL_SERVER_PASSWORD=
    command: --interval 86400

The --interval 86400 runs once every 24 hours; I align the start time to 04:00 server time so updates land outside business hours. WATCHTOWER_CLEANUP=true deletes the old image tag after each upgrade. Without it, six months of weekly Vaultwarden releases can leave 3-4GB of dangling layers on the disk.

The bootstrap order that actually works

There is a small chicken-and-egg problem in this stack: Vaultwarden is where you store the credentials NPM and Portainer use, but you need NPM up before you can give Vaultwarden a TLS hostname. The order I follow on every fresh box:

  1. Install Docker. Run the hello-world test.
  2. Install Portainer with a strong throwaway admin password. Write it on paper if you have to.
  3. Install NPM. Change the default credentials on first login. Add a proxy host for Portainer itself, with TLS.
  4. Install Vaultwarden. Add a proxy host for it. Sign up the first user, verify the email.
  5. Set SIGNUPS_ALLOWED=false and restart the container.
  6. Move every credential from paper into Vaultwarden: the Portainer admin, the NPM admin, the SSH key, the DNS account.
  7. Set up Watchtower last. Exclude Vaultwarden from auto-updates.

The whole sequence takes about 90 minutes the first time and 30 minutes once you have done it twice. Keep the compose files in a git repo so step seven on the next box is a git clone and a series of docker compose up -d calls.

What I do not do on this stack

A few things people often layer on top of this trio that I have stopped bothering with:

  • TLS directly on Portainer / Vaultwarden. NPM does it better and from one place. Internal HTTP is fine when the only client is NPM on the same Docker network.
  • Portainer’s own user database for non-admin staff. I use Authentik as the identity provider and let Portainer trust it via OAuth. One place to disable a leaving employee.
  • Vaultwarden organizations for client password sharing. They work, but I keep client credentials in a separate Vaultwarden instance per client tier so a single compromised master password cannot expose three customers at once.
  • Backup as an afterthought. The Vaultwarden data volume is the entire vault. I take an encrypted offsite snapshot every night and a manual export before any major upgrade. If you only do one thing differently from this guide, do that.

Closing the loop

This three-tool stack of Portainer, Nginx Proxy Manager, and Vaultwarden has been the default management layer on every Webnestify-managed Docker host for three years now. It is not the most powerful setup possible. It is small enough to run on a 2GB VPS, cheap enough to deploy on every client environment, and operable by anyone on the team after about a day of pairing.

The point of self-hosting this layer is not heroics. It is owning the parts of the stack you cannot tolerate losing access to during an incident. When the credentials, the dashboard, and the gateway all live on infrastructure I control, the worst case looks a lot less terrifying than the alternative.

Watch on YouTube

Video walkthrough

Prefer the screen-recording version of this guide? Watch it on YouTube — opens in a new tab so the player only loads when you ask for it.

Frequently Asked Questions

Want this handled, not just understood?

Reading the playbook is one thing. Running it on production at 2am is another. If you'd rather have me run it for you, the door is open.

Apply for Access