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 introduced 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.
Prerequisites
You must have Docker Engine installed. If you don’t already have it, follow the official Docker documentation.
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
-
Create a DNS record pointing to your server’s IP address. Example: using Cloudflare
-
Update the
HOST_URL
andWEB_CLIENT_URL
in settings.yaml accordingly.
3.2 Configure SMTP for Email Registration
We will use Gmail for SMTP in this example:
- Ensure you have a Gmail account.
- Enable 2-Step Verification.
- Create an App Password specifically for Psono.
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: 465EMAIL_SUBJECT_PREFIX: ''EMAIL_USE_TLS: FalseEMAIL_USE_SSL: TrueEMAIL_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.
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 updatesudo apt install certbotsudo 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-databasedocker 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
- Visit https://psono.example.com and register a new account.
- Confirm your registration via the activation email.
- 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:
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.