Deploying a Secure Self-Hosted Password Manager with Psono and Docker | CodeCrafted

Deploying a Secure Self-Hosted Password Manager with Psono and Docker

Apr 26, 2025

As the University of Calgary Cybersecurity Club, also known as CYBERSEC , continues to grow — now with around 30 members — the need for a more efficient and secure method to store credentials has become evident.

Previously, we stored all of our credentials in a shared Bitwarden vault . While this solution worked, it introduce several challenges. Most notably, compromising the master key would jeopardize the entire organization. Additionally, we aim to follow the principle of “least privilege” access, streamline the onboarding and offboarding of members, and have better forensic capabilities in case of a breach.

After some research, we decided to implement our own instance of Psono . We’ll primarily follow Psono’s official documentation, making modifications where necessary and highlighting important notes along the way.

Index


Prerequisites

You must have Docker Engine installed. If you don’t already have it, follow the official Docker documentation here .

Step 1: Set Up the PostgreSQL Database

We’ll start by running PostgreSQL in a Docker container:

First create a volume folder for your data:

sudo mkdir -p /opt/docker/psono/postgres

Second Run the database:

docker run --name psono-database \
 -v /opt/docker/psono/postgres:/var/lib/postgresql/data \
 -e POSTGRES_USER=$POSTGRES_USER \
 -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \
 -d --restart=unless-stopped \
 -p 5432:5432 postgres:13-alpine

It’s recommended to store sensitive information like POSTGRES_USER and POSTGRES_PASSWORD in a .env file.


Step 2: Generate the Required Keys

docker run --rm -ti psono/psono-combo:latest python3 ./psono/manage.py generateserverkeys

Step 3: Configure settings.yaml

Start by copying the template provided in the Psono installation guide . Add them to /opt/docker/psono/settings.yaml.

3.1 Set the Host URL

3.2 Configure SMTP for Email Registration

We will use Gmail for SMTP in this example:

  1. Ensure you have a Gmail account.
  2. Enable 2-Step Verification.
  3. Create an App Password specifically for Psono.
  4. Test your SMTP settings using GMass SMTP Test .

Then, configure settings.yaml:

EMAIL_FROM: 'user@gmail.com'
EMAIL_HOST: 'smtp.gmail.com'
EMAIL_HOST_USER: 'smtp.something@gmail.com'
EMAIL_HOST_PASSWORD: 'fake pass word yeah'
EMAIL_PORT: 465
EMAIL_SUBJECT_PREFIX: ''
EMAIL_USE_TLS: False
EMAIL_USE_SSL: True
EMAIL_SSL_CERTFILE:
EMAIL_SSL_KEYFILE:
EMAIL_TIMEOUT: 10

To verify the SMTP configuration:

docker run --rm \
  -v /opt/docker/psono/settings.yaml:/root/.psono_server/settings.yaml \
  -ti psono/psono-combo:latest python3 ./psono/manage.py sendtestmail something@something.com

3.3 Configure the Database Connection

Add the database configuration to your settings.yaml:

DATABASES:
  default:
    ENGINE: 'django.db.backends.postgresql_psycopg2'
    NAME: 'user'
    USER: 'user'
    PASSWORD: 'securepasswordforsure'
    HOST: 'psono-database'
    PORT: '5432'

Step 4: Prepare the Database

When running the default migration command provided in Psono’s documentation:

docker run --rm \
  -v /opt/docker/psono/settings.yaml:/root/.psono_server/settings.yaml \
  -ti psono/psono-combo:latest python3 ./psono/manage.py migrate

You may encounter connection issues because containers connected to the default bridge network cannot resolve each other’s names reliably.

Solution:

  1. Create a custom Docker network:
docker network create psono-network
  1. Connect the database container:
docker network connect psono-network psono-database
  1. Then migrate using:
docker run --rm \
  --network psono-network \
  -v /opt/docker/psono/settings.yaml:/root/.psono_server/settings.yaml \
  -ti psono/psono-combo:latest python3 ./psono/manage.py migrate

Tip:
Using Docker Compose makes this process much cleaner. Here’s a minimal example:

services:
  psono-database:
    image: postgres:13-alpine
    container_name: psono-database
    environment:
      POSTGRES_USER: psono
      POSTGRES_PASSWORD: password
    volumes:
      - /opt/docker/psono/postgres:/var/lib/postgresql/data
    networks:
      - psono-network

  psono:
    image: psono/psono-combo:latest
    container_name: psono
    volumes:
      - /opt/docker/psono/settings.yaml:/root/.psono_server/settings.yaml
    networks:
      - psono-network
    depends_on:
      - psono-database

networks:
  psono-network:
    driver: bridge

Step 5: Set Up the Client

Create a config.json (e.g., /opt/docker/psono-client/config.json):

{
  "backend_servers": [{
    "title": "Psono.pw",
    "url": "https://psono.example.com/server"
  }],
  "base_url": "https://psono.example.com/",
  "allow_custom_server": true,
  "allow_registration": true,
  "allow_lost_password": true,
  "disable_download_bar": false,
  "remember_me_default": false,
  "trust_device_default": false,
  "authentication_methods": ["AUTHKEY", "LDAP"],
  "saml_provider": []
}

Make sure to replace psono.example.com with your domain name.

You can also add a "domain": "other.com", entry to specify an alternative login domain. If you do, make sure to also include it in the ALLOWED_DOMAINS: ['example.com'] section of your settings.yaml.


Step 6: Run the Server

Start the server manually for testing:

docker run --name psono-combo \
  --sysctl net.core.somaxconn=65535 \
  --network psono-network \
  -v /opt/docker/psono/settings.yaml:/root/.psono_server/settings.yaml \
  -v /opt/docker/psono-client/config.json:/usr/share/nginx/html/config.json \
  -v /opt/docker/psono-client/config.json:/usr/share/nginx/html/portal/config.json \
  -d --restart=unless-stopped \
  -p 10200:80 \
  psono/psono-combo:latest

Visit http://your-ip:10200/server/info/ to verify that the server is running.


Step 7: Set Up a Reverse Proxy with SSL

Install Nginx

sudo apt install nginx

Install Certbot

sudo apt update
sudo apt install certbot
sudo apt install python3-certbot

Obtain the SSL Certificate

sudo certbot certonly --standalone -d psono.example.com

Provide an email address for renewal reminders.

Configure Nginx

Create /etc/nginx/sites-available/psono.example.com.conf:

server {
    listen 80;
    server_name psono.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name psono.example.com;

    ssl_certificate /etc/letsencrypt/live/psono.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/psono.example.com/privkey.pem;

    proxy_redirect off;

    ssl_protocols TLSv1.2;
    ssl_prefer_server_ciphers on;
    ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:...';
    
    add_header Referrer-Policy same-origin;
    add_header X-Frame-Options DENY;
    add_header X-Content-Type-Options nosniff;
    add_header X-XSS-Protection "1; mode=block";

    add_header Content-Security-Policy "...";

    client_max_body_size 256m;

    gzip on;
    gzip_types text/plain text/css application/json application/javascript ...;

    root /var/www/html;

    location ~* \.(?:ico|css|js|gif|jpe?g|png|eot|woff|woff2|ttf|svg|otf)$ {
        expires 30d;
        proxy_pass http://localhost:10200;
        proxy_redirect http://localhost:10200 https://psono.example.com;
        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;
    }

    location / {
        proxy_pass http://localhost:10200;
        proxy_redirect http://localhost:10200 https://psono.example.com;
        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;
    }
}

Enable the configuration:

sudo ln -s /etc/nginx/sites-available/psono.example.com.conf /etc/nginx/sites-enabled/
sudo service nginx restart

Now your Psono server should be live at https://psono.example.com.


Step 8: Set Up Docker Compose

Stop any running containers:

docker stop psono-database
docker stop psono-combo

Create /opt/docker/psono/docker-compose.yml:

networks:
  psono-network:
    external: true

services:
  psono-database:
    image: postgres:13-alpine
    container_name: psono-database
    restart: unless-stopped
    environment:
      POSTGRES_USER: ${POSTGRES_USER}
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - /opt/docker/psono/postgres:/var/lib/postgresql/data
    ports:
      - "5432:5432"
    networks:
      - psono-network

  psono-server:
    image: psono/psono-combo:latest
    container_name: psono-combo
    restart: unless-stopped
    sysctls:
      net.core.somaxconn: 65535
    volumes:
      - /opt/docker/psono/settings.yaml:/root/.psono_server/settings.yaml
      - /opt/docker/psono-client/config.json:/usr/share/nginx/html/config.json
      - /opt/docker/psono-client/config.json:/usr/share/nginx/html/portal/config.json
    ports:
      - "10200:80"
    depends_on:
      - psono-database
    networks:
      - psono-network

Bring up the stack:

docker compose up -d

Step 9: Schedule a Cron Job to Clear Tokens

Edit your crontab:

crontab -e

Add the following line:

30 2 * * * /usr/bin/docker exec psono-combo python3 ./psono/manage.py cleartoken >> /var/log/cron.log 2>&1

Step 10: Create and Promote an Admin User

To promote the user to admin:

docker run --rm \
  --network psono_default \
  -v /opt/docker/psono/settings.yaml:/root/.psono_server/settings.yaml \
  -ti psono/psono-combo:latest python3 ./psono/manage.py promoteuser mcveigth@psono.example.com superuser

🎉 E Voilà!

At this point, your Psono server should be fully operational — secured with SSL, backed by a persistent PostgreSQL database, and ready for user management.

Here’s a screenshot of everything running smoothly:

Psono running successfully with Docker

If you see something similar, congratulations — you now have a secure, self-hosted password management system tailored to your organization’s needs!


Final Notes

By deploying a secure, self-hosted Psono instance, we’ve taken a big step forward in protecting CYBERSEC’s credentials and improving operational security. This setup not only ensures better control over who can access sensitive information, but also allows us to enforce the principle of least privilege, streamline member onboarding and offboarding, and maintain forensic visibility in case of any issues.

Thanks to Docker, PostgreSQL, Nginx, and SSL encryption, our infrastructure is now production-ready and built to scale as the club continues to grow.

With the foundation in place, the next phase will focus on fine-tuning user and group policies to ensure that every member has exactly the access they need — no more, no less. This will further strengthen our security posture and support the club’s long-term success.

In a future article, we’ll cover how to create users, organize them into teams, configure permission sets, and enforce access control policies effectively.