• Dec. 2, 2025, 9:26 a.m.

    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-6 is used because the image hardcodes this hostname.
    • --static-map flags are added to the command to force uWSGI to serve CSS/JS files.
    • The local_settings.py is mounted to settings_override.py inside 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:

    1. Log in to YunoHost Admin.
    2. Install the Redirect application.
    3. Configuration:
      • Domain: misago.tcp-ip.my
      • Path: /
      • Type: Proxy
      • Destination: http://127.0.0.1:49941

    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 Host header (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.py to explicitly set ALLOWED_HOSTS = ['*'] and SECURE_PROXY_SSL_HEADER. We mapped this file to /misago/misagodocker/settings_override.py because local_settings.py was ignored by the container logic.

    Issue: Unstyled Page (Ugly formatting)

    • Symptom: Site works but looks like plain text; browser console shows 404 errors for .css files.
    • Cause: uWSGI (the app server) does not serve static files by default.
    • Fix:
      1. Added --static-map to the Docker command.
      2. Ran chown to ensure www-data owned the files.
      3. Restarted the container.

    Issue: Redis Connection Error

    • Symptom: Error connecting to redis-6.
    • Cause: The Docker image has redis-6 hardcoded as the default hostname.
    • Fix: Renamed the redis service in docker-compose.yml from redis to redis-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:

    1. Extract the original footer.html template from the image.
    2. Append a custom JavaScript/CSS snippet to it.
    3. 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

    1. Clear your browser cache (or use Incognito mode) to ensure the new HTML loads.
    2. Navigate to a forum thread containing a code block.
    3. Hover over the code block; a "Copy" button should appear in the top-right corner.
    4. 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.