1. Context & Architecture
This guide documents the successful deployment of Misago, a modern Python/Django-based forum software, on an AMD EPYC (x86_64) VPS running Debian 12.
The ecosystem is a "Hybrid" setup:
- Host OS: YunoHost (manages DNS, SSL, and Reverse Proxy).
- Application Runtime: Docker & Docker Compose.
- Database: PostgreSQL 15 (Containerised).
- Cache: Redis 7 (Containerised).
- Reverse Proxy: YunoHost internal Nginx proxying to localhost port
49941.
2. Prerequisites
- Docker and Docker Compose installed.
- A registered domain pointed to the server (e.g.,
misago.tcp-ip.my). - YunoHost "Redirect" application installed (for reverse proxying).
3. Directory Structure
Create a dedicated directory to keep the project isolated:
mkdir -p ~/misago-forum
cd ~/misago-forum
mkdir -p data/db data/media data/static data/logs
4. Configuration Files
A. local_settings.py (The Security Fix)
Why this is needed: The Docker image (tetricky/misago-image) ignores certain environment variables regarding allowed hosts. We must inject a Python configuration file to force Django to trust the YunoHost reverse proxy headers and prevent "Bad Request (400)" errors.
Create the file: nano local_settings.py
# Force Misago to allow all hosts
# (Safe because YunoHost Nginx handles the firewall/filtering)
ALLOWED_HOSTS = ['*']
MISAGO_ALLOWED_HOSTS = ['*']
# Force Misago to trust YunoHost's SSL/HTTPS connection headers
USE_X_FORWARDED_HOST = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# Trust the origin for CSRF (Required for logging in)
# Replace with your actual domain
CSRF_TRUSTED_ORIGINS = ['https://misago.tcp-ip.my']
# Disable Debug mode for production security
DEBUG = False
MISAGO_DEBUG = False
B. docker-compose.yml (The Orchestrator)
Key details:
- Service name
redis-6is used because the image hardcodes this hostname. --static-mapflags are added to the command to force uWSGI to serve CSS/JS files.- The
local_settings.pyis mounted tosettings_override.pyinside the container.
Create the file: nano docker-compose.yml
version: '3'
services:
db:
image: postgres:15
restart: unless-stopped
environment:
POSTGRES_DB: misago
POSTGRES_USER: misago
POSTGRES_PASSWORD: ChangeThisToYourStrongPassword
volumes:
- ./data/db:/var/lib/postgresql/data
# Service must be named 'redis-6' due to hardcoded image settings
redis-6:
image: redis:7
restart: unless-stopped
misago:
image: tetricky/misago-image:latest
restart: unless-stopped
depends_on:
- db
- redis-6
# Command includes static maps to ensure styling loads correctly
command: uwsgi --ini uwsgi.ini --http 0.0.0.0:80 --static-map /static=/misago/static --static-map /media=/misago/media
ports:
# Maps localhost:49941 to container:80
- "127.0.0.1:49941:80"
environment:
# --- MISAGO CONFIG ---
MISAGO_SECRET_KEY: "ChangeThisToSomethingRandomAndComplex"
MISAGO_ADDRESS: "https://misago.tcp-ip.my"
# --- DATABASE CONNECTIONS ---
MISAGO_DB_HOST: db
MISAGO_DB_PORT: "5432"
MISAGO_DB_NAME: misago
MISAGO_DB_USER: misago
MISAGO_DB_PASSWORD: ChangeThisToYourStrongPassword
# System overrides for the postgres driver
PGHOST: db
PGPORT: "5432"
PGUSER: misago
PGPASSWORD: ChangeThisToYourStrongPassword
# --- REDIS CONNECTIONS ---
MISAGO_REDIS_HOST: redis-6
REDIS_HOST: redis-6
CACHE_HOST: redis-6
volumes:
- ./data/media:/misago/media
- ./data/static:/misago/static
- ./data/logs:/misago/logs
# INJECTION: Map our local settings to the override file the app expects
- ./local_settings.py:/misago/misagodocker/settings_override.py
5. Installation Procedure
1. Start the Containers
docker compose up -d
2. Create Superuser (Admin)
Note: Database migrations usually run automatically with this image. If not, run python manage.py migrate first.
docker compose run --rm misago python manage.py createsuperuser
3. Fix Permissions
The container runs as a specific user, but volumes on the host might be owned by root. We explicitly set ownership to www-data (the web user) so the application can write static files.
docker compose run --rm misago chown -R www-data:www-data /misago/static /misago/media
4. Collect Static Files
Generates the CSS, JS, and image files required for the site to look correct.
docker compose run --rm misago python manage.py collectstatic --noinput
5. Final Restart
Required for uWSGI to register the new static maps.
docker compose restart misago
6. YunoHost Reverse Proxy Setup
To expose the forum to the public internet securely:
- Log in to YunoHost Admin.
- Install the Redirect application.
- Configuration:
- Domain:
misago.tcp-ip.my - Path:
/ - Type: Proxy
- Destination:
http://127.0.0.1:49941
- Domain:
7. Troubleshooting & Lessons Learnt
Issue: Bad Request (400) on HTTPS
- Symptom: The site loads a white page saying "Bad Request (400)".
- Cause: Django blocks the request because the
Hostheader (misago.tcp-ip.my) does not match the internal container IP, and it doesn't trust the YunoHost proxy claiming the connection is secure (HTTPS). - Fix: Created
local_settings.pyto explicitly setALLOWED_HOSTS = ['*']andSECURE_PROXY_SSL_HEADER. We mapped this file to/misago/misagodocker/settings_override.pybecauselocal_settings.pywas ignored by the container logic.
Issue: Unstyled Page (Ugly formatting)
- Symptom: Site works but looks like plain text; browser console shows 404 errors for
.cssfiles. - Cause: uWSGI (the app server) does not serve static files by default.
- Fix:
- Added
--static-mapto the Docker command. - Ran
chownto ensurewww-dataowned the files. - Restarted the container.
- Added
Issue: Redis Connection Error
- Symptom: Error connecting to
redis-6. - Cause: The Docker image has
redis-6hardcoded as the default hostname. - Fix: Renamed the redis service in
docker-compose.ymlfromredistoredis-6.
8. Maintenance Commands
Check Logs:
docker compose logs misago --tail=50
Backup Database:
docker compose exec db pg_dump -U misago misago > backup_$(date +%F).sql
Update Software:
docker compose pull
docker compose up -d
docker compose run --rm misago python manage.py migrate
Edit 1
I've noticed that the latest Docker software always outputs a warning that the version attribute inside docker-compose.yml is obsolete and advises us to remove it to avoid potential confusion. I have removed it, and my Docker software installation worked flawlessly.
Edit 2: Adding "Copy to Clipboard" for Code Blocks (Misago v0.39)
1. The Strategy
Instead of modifying the core Python files inside the container (which would be lost on update), we:
- Extract the original
footer.htmltemplate from the image. - Append a custom JavaScript/CSS snippet to it.
- Mount the modified file back into the container using
docker-compose.yml.
2. Implementation Steps
Step A: Extract the Template
Run the following command to copy the global footer template from the running container to your host directory:
docker compose cp misago:/usr/local/lib/python3.12/site-packages/misago/templates/misago/footer.html footer.html
Step B: Inject the Script
Open the newly created footer.html file:
nano footer.html
Scroll to the very bottom of the file (after the closing </footer> tag) and append this code block. This script dynamically detects <pre> blocks and adds a floating copy button.
<script>
document.addEventListener("DOMContentLoaded", function() {
// 1. Find all <pre> blocks
const codeBlocks = document.querySelectorAll('pre');
codeBlocks.forEach(function(preBlock) {
// Prevent duplicate buttons
if (preBlock.querySelector('.copy-btn')) return;
// 2. Create the Copy Button
const button = document.createElement('button');
button.className = 'copy-btn';
button.innerText = 'Copy';
button.type = 'button';
// 3. Positioning
preBlock.style.position = 'relative';
// 4. Add Click Functionality (Clipboard API)
button.addEventListener('click', function() {
// Get text, handling potential nested <code> tags
const codeText = preBlock.querySelector('code') ? preBlock.querySelector('code').innerText : preBlock.innerText;
navigator.clipboard.writeText(codeText).then(function() {
// Success Feedback
const originalText = button.innerText;
button.innerText = 'Copied!';
button.classList.add('copied');
// Reset after 2 seconds
setTimeout(function() {
button.innerText = originalText;
button.classList.remove('copied');
}, 2000);
}).catch(function(err) {
console.error('Failed to copy text: ', err);
button.innerText = 'Error';
});
});
// 5. Append button
preBlock.appendChild(button);
});
});
</script>
<style>
/* Button Styling */
.copy-btn {
position: absolute;
top: 5px;
right: 5px;
padding: 4px 8px;
background-color: #f5f5f5;
color: #333;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 12px;
font-family: sans-serif;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.2s, background-color 0.2s;
z-index: 10;
}
/* Hover Effects */
pre:hover .copy-btn {
opacity: 1;
}
.copy-btn.copied {
background-color: #28a745;
color: white;
border-color: #28a745;
}
</style>
Step C: Update Docker Configuration
Modify your docker-compose.yml to override the internal template with your local version.
Add this line to the volumes section under the misago service:
volumes:
# ... (existing volumes) ...
# OVERRIDE: Inject custom footer with Copy button script
- ./footer.html:/usr/local/lib/python3.12/site-packages/misago/templates/misago/footer.html
Step D: Apply Changes
Force the container to recreate and map the new file:
docker compose up -d --force-recreate
3. Verification
- Clear your browser cache (or use Incognito mode) to ensure the new HTML loads.
- Navigate to a forum thread containing a code block.
- Hover over the code block; a "Copy" button should appear in the top-right corner.
- Clicking it should change the text to "Copied!" and place the code into your clipboard.
Edit 3: Making the Copy Button Invisible by Default
This update modifies the appearance of the custom "Copy" button to be transparent by default, minimizing screen clutter and allowing users to see the code clearly. It remains clickable (tappable) in the top-right corner but only becomes visible on mouse hover.
1. The CSS Strategy
The original CSS set the default opacity to 0.5. To make the button invisible but still functionally present, we change the default opacity to 0. The pre:hover rule ensures it becomes fully visible on desktop systems.
2. Implementation Steps
Step A: Update the CSS in footer.html
Open your footer.html file and locate the <style> block.
Replace the entire <style> block with the code below. The critical change is setting opacity: 0 !important; in the default state and ensuring the pre:hover rule sets it back to 1.
<style>
/* Button Styling */
.copy-btn {
position: absolute;
top: 5px;
right: 5px;
padding: 4px 8px;
background-color: #f5f5f5; /* Light grey */
color: #333;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 12px;
font-family: sans-serif;
cursor: pointer;
/* FIX: Make invisible by default but clickable */
opacity: 0;
transition: opacity 0.2s, background-color 0.2s;
z-index: 10;
}
/* Make it fully visible on mouse hover */
pre:hover .copy-btn {
opacity: 1;
}
/* Ensure 'Copied!' feedback is always fully visible */
.copy-btn.copied {
opacity: 1 !important;
background-color: #28a745;
color: white;
border-color: #28a745;
}
</style>
Step B: Apply Changes
After saving footer.html (Ctrl+O, Enter), apply the changes to your container:
docker compose up -d --force-recreate
3. Verification & Behaviour
- Desktop Behaviour: The button will only appear when the mouse enters the code block area.
- Touchscreen Behaviour: The button remains transparent, but tapping the top-right corner of the code block will still trigger the copy function and display the visible "Copied!" notification. This ensures functionality is not lost for mobile users.