├── .gitignore ├── .dockerignore ├── Dockerfile ├── .env.sample ├── docker-compose.yml ├── README.md ├── contrib └── compose │ └── nginx_medium_rewrite_urls.conf ├── DOKKU_DEPLOYMENT.md └── nginx.conf.sigil /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | *.md 4 | .env 5 | .env.sample 6 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | ARG GHOST_VERSION=6.7.0 2 | FROM ghost:${GHOST_VERSION}-alpine 3 | 4 | # Add the Object Store storage adapter. We use the main branch of the repository. 5 | ADD --chown=node:node https://github.com/CodeForAfrica/ghost-object-store-storage-adapter.git#main content/adapters/storage/object-store 6 | 7 | # Install dependencies for the storage adapter. 8 | WORKDIR /var/lib/ghost/content/adapters/storage/object-store 9 | RUN npm install --omit=dev 10 | 11 | # Return to the original working directory. 12 | WORKDIR /var/lib/ghost 13 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | # MinIO Configuration 2 | MINIO_ROOT_USER=admin 3 | MINIO_ROOT_PASSWORD=SuperSecurePassword 4 | MINIO_ENDPOINT=http://minio:9000 5 | MINIO_BUCKET_NAME=my-ghost-bucket 6 | MINIO_ACCESS_KEY=your-access-key 7 | MINIO_SECRET_KEY=your-secret-key 8 | MINIO_REGION=eu-west-1 9 | MINIO_USE_SSL=false 10 | 11 | # MySQL Configuration 12 | MYSQL_HOST=db 13 | MYSQL_ROOT_PASSWORD=R00tP@ssw0rd 14 | MYSQL_DATABASE=ghost 15 | 16 | # Ghost Configuration 17 | url=http://localhost:8069 18 | database__client=mysql 19 | database__connection__host=${MYSQL_HOST} 20 | database__connection__user=root 21 | database__connection__password=${MYSQL_ROOT_PASSWORD} 22 | database__connection__database=${MYSQL_DATABASE} 23 | imageOptimization__resize=false 24 | imageOptimization__srcsets=false 25 | 26 | storage__active=object-store 27 | storage__files__adapter=object-store 28 | storage__media__adapter=object-store 29 | 30 | storage__objectStore__endpoint=${MINIO_ENDPOINT} 31 | storage__objectStore__accessKey=${MINIO_ACCESS_KEY} 32 | storage__objectStore__secretKey=${MINIO_SECRET_KEY} 33 | storage__objectStore__bucket=${MINIO_BUCKET_NAME} 34 | storage__objectStore__region=${MINIO_REGION} 35 | storage__objectStore__useSSL=${MINIO_USE_SSL} 36 | storage__objectStore__storagePath=content/media/ 37 | storage__objectStore__staticFileURLPrefix=content/media/ 38 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | 3 | openresty: 4 | image: openresty/openresty:1.27.1.2-0-alpine 5 | restart: always 6 | ports: 7 | - 8069:8069 8 | volumes: 9 | - ./contrib/compose/nginx_medium_rewrite_urls.conf:/etc/nginx/conf.d/nginx_medium_rewrite_urls.conf 10 | depends_on: 11 | - ghost 12 | 13 | ghost: 14 | build: 15 | context: . 16 | restart: always 17 | depends_on: 18 | db: 19 | condition: service_healthy 20 | minio: 21 | condition: service_healthy 22 | env_file: .env 23 | volumes: 24 | - ghost:/var/lib/ghost/content 25 | 26 | db: 27 | image: mysql:8.4.7 28 | restart: always 29 | healthcheck: 30 | test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-p$${MYSQL_ROOT_PASSWORD}"] 31 | interval: 7s 32 | timeout: 5s 33 | retries: 3 34 | ports: 35 | - 13306:3306 36 | env_file: .env 37 | volumes: 38 | - db:/var/lib/mysql 39 | 40 | minio: 41 | image: minio/minio:RELEASE.2025-09-07T16-13-09Z 42 | restart: always 43 | ports: 44 | - "9000:9000" 45 | - "9001:9001" 46 | volumes: 47 | - minio_data:/data 48 | env_file: .env 49 | command: server --console-address ":9001" /data 50 | healthcheck: 51 | test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"] 52 | interval: 10s 53 | timeout: 5s 54 | retries: 3 55 | 56 | initialize-minio: 57 | image: quay.io/minio/mc 58 | depends_on: 59 | - minio 60 | entrypoint: > 61 | /bin/sh -c ' 62 | until (/usr/bin/mc alias set minio http://minio:9000 "$${MINIO_ROOT_USER}" "$${MINIO_ROOT_PASSWORD}") do echo "...waiting..." && sleep 1; done; 63 | /usr/bin/mc mb minio/"$${MINIO_BUCKET_NAME}"; 64 | /usr/bin/mc anonymous set download minio/"$${MINIO_BUCKET_NAME}"; 65 | /usr/bin/mc admin user add minio "$${MINIO_ACCESS_KEY}" "$${MINIO_SECRET_KEY}"; 66 | /usr/bin/mc admin policy attach minio readwrite --user "$${MINIO_ACCESS_KEY}"; 67 | exit 0; 68 | ' 69 | env_file: .env 70 | 71 | volumes: 72 | ghost: 73 | db: 74 | minio_data: 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ghost Blog with Object Storage 2 | 3 | A production-ready Ghost blog platform configured with MinIO object storage for media files, using Docker Compose for container orchestration. 4 | 5 | This file covers local development and testing. For deployment to Dokku, see [DOKKU_DEPLOYMENT.md](DOKKU_DEPLOYMENT.md). 6 | 7 | ## Overview 8 | 9 | This project sets up a complete Ghost blogging platform with the following components: 10 | 11 | - **Ghost**: Version 6.7.0 - Modern publishing platform 12 | - **MySQL**: Version 8.4.7 - Database for content management 13 | - **MinIO**: S3-compatible object storage for media files 14 | - **Custom Storage Adapter**: Object store adapter for S3-compatible storage 15 | 16 | ## Prerequisites 17 | 18 | - Docker Engine 19 | - Docker Compose 20 | - At least 2GB of RAM available 21 | 22 | ## Quick Start 23 | 24 | 1. Clone this repository 25 | 2. Copy the sample environment file: 26 | ```bash 27 | cp .env.sample .env 28 | ``` 29 | 3. Review and customize the environment variables in `.env` as needed 30 | 4. Start the services: 31 | ```bash 32 | docker compose up 33 | ``` 34 | 5. Access Ghost at `http://localhost:8069` 35 | 6. Access MinIO Console at `http://localhost:9001` 36 | 37 | ## Configuration 38 | 39 | ### Environment Variables 40 | 41 | The `.env` file contains configuration for all services: 42 | 43 | **MinIO Configuration:** 44 | - `MINIO_ROOT_USER` - MinIO admin username 45 | - `MINIO_ROOT_PASSWORD` - MinIO admin password 46 | - `MINIO_ENDPOINT` - MinIO endpoint URL 47 | - `MINIO_BUCKET_NAME` - Bucket name for media storage 48 | - `MINIO_ACCESS_KEY` - Access key for object storage 49 | - `MINIO_SECRET_KEY` - Secret key for object storage 50 | - `MINIO_REGION` - Region for object storage 51 | - `MINIO_USE_SSL` - SSL usage flag (true/false) 52 | 53 | **MySQL Configuration:** 54 | - `MYSQL_HOST` - Database host name 55 | - `MYSQL_ROOT_PASSWORD` - Root password for MySQL 56 | - `MYSQL_DATABASE` - Database name 57 | 58 | **Ghost Configuration:** 59 | - `url` - Public URL for the Ghost instance 60 | - Database connection settings 61 | - Image optimization settings 62 | - Object storage adapter settings 63 | 64 | ### Services 65 | 66 | - **Ghost**: Runs on port 8069 (mapped from 2368) 67 | - **MySQL**: Runs on port 13306 (mapped from 3306) 68 | - **MinIO**: Runs on ports 9000 (API) and 9001 (Console) 69 | 70 | ## Architecture 71 | 72 | The system uses a [custom object storage adapter](https://github.com/CodeForAfrica/ghost-object-store-storage-adapter.git) to store media files in MinIO instead of the local filesystem. This setup provides: 73 | 74 | - Scalable media storage 75 | - Better separation of concerns 76 | - Easy backup and migration 77 | - S3-compatible storage backend 78 | 79 | ## Volumes 80 | 81 | The following volumes are created for data persistence: 82 | - `ghost` - Ghost content (themes, apps, images) 83 | - `db` - MySQL database data 84 | - `minio_data` - MinIO object storage data 85 | 86 | ## Development 87 | 88 | To build and run the application: 89 | 90 | ```bash 91 | # Build and start all services 92 | docker compose up --build -d 93 | 94 | # View logs 95 | docker compose logs -f 96 | 97 | # Stop services 98 | docker compose down 99 | 100 | # Stop and remove volumes (data will be lost) 101 | docker compose down -v 102 | ``` 103 | 104 | ## Troubleshooting 105 | 106 | ### Common Issues 107 | 108 | 1. **Ghost fails to start**: Check that MySQL and MinIO are healthy before Ghost starts 109 | 2. **Media upload fails**: Verify MinIO connectivity and bucket permissions 110 | 3. **Database connection errors**: Check MySQL health and credentials in `.env` 111 | 4. **MinIO not accessible**: Confirm that the initialization container completed successfully 112 | 113 | ### Health Checks 114 | 115 | Each service includes health checks: 116 | - MySQL: Pings the database every 7 seconds 117 | - MinIO: Checks health endpoint every 10 seconds 118 | - Ghost: Starts after both MySQL and MinIO are healthy 119 | 120 | ### Logs 121 | 122 | View service logs with: 123 | ```bash 124 | docker compose logs ghost 125 | docker compose logs db 126 | docker compose logs minio 127 | ``` 128 | -------------------------------------------------------------------------------- /contrib/compose/nginx_medium_rewrite_urls.conf: -------------------------------------------------------------------------------- 1 | # This file should live in `/etc/nginx/conf.d/` or wherever your preferred flavour of Nginx expects to find config files. 2 | # 3 | # The central idea of this Nginx conf file is converting URLs containing non-ASCII characters into a format that Ghost understands 4 | # and doing the corresponding redirects. This is necessitated by the fact that Ghost doesn't really have i18n support for URLs and slugs. 5 | # 6 | # Why do all this? So that we can handle Medium URLs on Ghost seamlessly. 7 | # 8 | # This means if a user visits 9 | # https://pesacheck.org/partiellement-faux-le-gouvernement-sénégalais-na-pas-été-dissout-après-les-manifestations-du-19-5a7e55071173 10 | # They get transparently redirected to 11 | # https://pesacheck.org/partiellement-faux-le-gouvernement-s-c3-a9n-c3-a9galais-na-pas--c3-a9t-c3-a9-dissout-apr-c3-a8s-les-manifestations-du-19 12 | # Which is the URL of the post within Ghost 13 | # 14 | # Example conversions: 15 | # 16 | # French 17 | # partiellement-faux-le-gouvernement-sénégalais-na-pas-été-dissout-après-les-manifestations-du-19 -> 18 | # partiellement-faux-le-gouvernement-s-c3-a9n-c3-a9galais-na-pas--c3-a9t-c3-a9-dissout-apr-c3-a8s-les-manifestations-du-19 19 | # 20 | # Amharic 21 | # የፈጠራ-ወሬ-የየመን-ሚሳኤል-በእስራኤል-ላይ-ያደረሰውን-ጥቃት-ያሳይሉ-የተባሉት-እነዚህ-ሁለት-ፎቶዎች-የተቀነባበሩ-ናቸውi -> 22 | # -e1-8b-a8-e1-8d-88-e1-8c-a0-e1-88-ab--e1-8b-88-e1-88-ac--e1-8b-a8-e1-8b-a8-e1-88-98-e1-8a-95--e1-88-9a-e1-88-b3-e1-8a-a4-e1-88-8d--e1-89-a0-e1-8a-a5-e1-88-b5-e1-88-ab-e1-8a-a4-e1-88-8d- 23 | # 24 | # English 25 | # false-ugandas-education-minister-janet-museveni-has-not-ordered-schools-to-end-their-third-term -> 26 | # false-ugandas-education-minister-janet-museveni-has-not-ordered-schools-to-end-their-third-term (No change as expected) 27 | # 28 | 29 | 30 | # Determine if the URL needs conversion by checking if it contains non-ASCII characters. 31 | map $uri $needs_conversion { 32 | ~[^\x00-\x7F] "yes"; 33 | default "no"; 34 | } 35 | 36 | server { 37 | listen 8069; 38 | server_name localhost; 39 | 40 | client_max_body_size 100M; 41 | 42 | # Remove the random ID that Medium appends to post URLs. This effectively does the same redirect recommended by Ghost here: 43 | # https://docs.ghost.org/migration/medium#using-custom-domains 44 | rewrite "^/(.*)(-[0-9a-f]{10,12})$" /$1 permanent; 45 | 46 | location / { 47 | # Check if conversion is needed using the map 48 | access_by_lua_block { 49 | if ngx.var.needs_conversion == "yes" then 50 | local function convert_string(input_str) 51 | -- Split the input string by hyphens 52 | local parts = {} 53 | for part in string.gmatch(input_str, "([^%-]+)") do 54 | table.insert(parts, part) 55 | end 56 | 57 | -- Process each part (same as above) 58 | local encoded_parts = {} 59 | for i, part in ipairs(parts) do 60 | -- Convert part to UTF-8 byte sequence and encode 61 | local encoded = "" 62 | for j = 1, #part do 63 | local byte = string.byte(part, j) 64 | if byte < 128 and string.match(string.char(byte), "[%w%-_.~]") then 65 | -- Keep safe ASCII characters as is 66 | encoded = encoded .. string.char(byte) 67 | else 68 | -- For all other bytes except the first one, use the -XX format 69 | if #encoded > 0 then 70 | encoded = encoded .. "-" .. string.format("%02x", byte):lower() 71 | else 72 | encoded = encoded .. string.format("%02x", byte):lower() 73 | end 74 | end 75 | end 76 | table.insert(encoded_parts, encoded) 77 | end 78 | 79 | -- Join all parts back with hyphens 80 | local result = table.concat(encoded_parts, "-") 81 | 82 | -- Truncate the URL at 185 characters in case it is longer to match what Ghost expects 83 | if #result > 185 then 84 | result = string.sub(result, 1, 185) 85 | end 86 | 87 | return result 88 | end 89 | 90 | -- Remove leading slash for processing 91 | local original_path = ngx.var.uri 92 | if string.sub(original_path, 1, 1) == "/" then 93 | original_path = string.sub(original_path, 2, -1) 94 | end 95 | 96 | local converted_path = convert_string(original_path) 97 | 98 | -- Redirect to the converted path 99 | ngx.redirect("/" .. converted_path, 301) 100 | return 101 | end 102 | } 103 | 104 | # Handle normal requests that don't need conversion 105 | proxy_pass http://ghost:2368; 106 | proxy_set_header Host $host; 107 | proxy_set_header X-Real-IP $remote_addr; 108 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 109 | proxy_set_header X-Forwarded-Proto $scheme; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /DOKKU_DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # Ghost Dokku Deployment Guide 2 | 3 | Instructions for deploying Ghost with external database and S3 object storage on Dokku. 4 | 5 | ## Prerequisites 6 | 7 | - Dokku instance set up and running 8 | - SSH access to your Dokku server 9 | - Git installed locally 10 | - External database (MySQL) with credentials 11 | - S3-compatible storage (AWS S3, DigitalOcean Spaces, etc.) with credentials 12 | 13 | ## Setup Instructions 14 | 15 | ### 1. Create the Dokku App 16 | 17 | ```bash 18 | # On your Dokku server 19 | dokku apps:create your-ghost-app-name 20 | ``` 21 | 22 | ### 2. Configure External Database 23 | 24 | Since we're using an external database: 25 | 26 | ```bash 27 | # Set environment variables for external database 28 | dokku config:set your-ghost-app-name \ 29 | database__client=mysql \ 30 | database__connection__host=your-db-host.com \ 31 | database__connection__user=your-db-user \ 32 | database__connection__password=your-db-password \ 33 | database__connection__database=your-db-name \ 34 | database__connection__port=3306 35 | ``` 36 | 37 | ### 3. Configure S3 Object Storage 38 | 39 | ```bash 40 | # Set S3 configuration environment variables 41 | dokku config:set your-ghost-app-name \ 42 | storage__active=object-store \ 43 | storage__files__adapter=object-store \ 44 | storage__media__adapter=object-store \ 45 | storage__objectStore__endpoint=https://s3.your-provider.com \ 46 | storage__objectStore__accessKey=your-s3-access-key \ 47 | storage__objectStore__secretKey=your-s3-secret-key \ 48 | storage__objectStore__bucket=your-s3-bucket-name \ 49 | storage__objectStore__region=your-s3-region \ 50 | storage__objectStore__useSSL=true \ 51 | storage__objectStore__storagePath=content/media/ \ 52 | storage__objectStore__staticFileURLPrefix=content/media/ 53 | ``` 54 | 55 | ### 4. Set Additional Ghost Configuration 56 | 57 | ```bash 58 | # Set Ghost URL and other configurations 59 | dokku config:set your-ghost-app-name \ 60 | url=https://your-domain.com \ 61 | NODE_ENV=production \ 62 | imageOptimization__resize=false \ 63 | imageOptimization__srcsets=false \ 64 | mail__from=noreply@yourdomain.com \ 65 | mail__transport=SMTP \ 66 | mail__options__host=smtp.smtp-provider.com \ 67 | mail__options__port=587 \ 68 | mail__options__secure="true" \ 69 | mail__options__auth__user=smtp-user \ 70 | mail__options__auth__pass=SuperSecureSMTPPassword 71 | ``` 72 | 73 | ### 5. Set up Nginx proxy 74 | 75 | Since we're using a Lua script in our Nginx config file, we need to install the Lua module. On Ubuntu, this can be done by running: 76 | 77 | ```sh 78 | sudo apt update && sudo apt install libnginx-mod-http-lua 79 | ``` 80 | 81 | The Lua module should automatically be enabled via symlinking. You can confirm this by checking the contents of `/etc/nginx/modules-enabled/`. 82 | 83 | ### 6. Deploy to Dokku 84 | 85 | ```bash 86 | # Add Dokku as a remote 87 | git remote add dokku dokku@your-server-ip:your-ghost-app-name 88 | 89 | # Push to deploy 90 | git push dokku main 91 | ``` 92 | 93 | ## Dokku-Specific Configuration Files 94 | 95 | ### Using a dokku.json file (for advanced configuration) 96 | 97 | If you need to specify Dokku-specific build settings, create a `dokku.json` file in your repository root: 98 | 99 | ```json 100 | { 101 | "image": "ghost:6.7.0-alpine", 102 | "proxy": { 103 | "web": { 104 | "port": 2368, 105 | "scheme": "http" 106 | } 107 | } 108 | } 109 | ``` 110 | 111 | ## Post-Deployment Steps 112 | 113 | ### 1. Set up SSL (recommended) 114 | 115 | ```bash 116 | dokku letsencrypt:enable your-ghost-app-name 117 | ``` 118 | 119 | ### 2. Configure Custom Domain 120 | 121 | ```bash 122 | dokku domains:add your-ghost-app-name your-domain.com 123 | ``` 124 | 125 | ### 3. Check Application Status 126 | 127 | ```bash 128 | dokku ps:report your-ghost-app-name 129 | dokku logs -f your-ghost-app-name 130 | ``` 131 | 132 | ## Environment Variables Summary 133 | 134 | Your Dokku app will need these environment variables: 135 | 136 | ### Database Configuration 137 | - `database__client` - Set to `mysql` 138 | - `database__connection__host` - External database host 139 | - `database__connection__user` - Database username 140 | - `database__connection__password` - Database password 141 | - `database__connection__database` - Database name 142 | - `database__connection__port` - Database port (default: 3306 for MySQL) 143 | 144 | ### S3 Object Storage Configuration 145 | - `storage__active` - Set to `object-store` 146 | - `storage__objectStore__endpoint` - S3 endpoint URL 147 | - `storage__objectStore__accessKey` - S3 access key 148 | - `storage__objectStore__secretKey` - S3 secret key 149 | - `storage__objectStore__bucket` - S3 bucket name 150 | - `storage__objectStore__region` - S3 region 151 | - `storage__objectStore__useSSL` - Enable SSL (true/false) 152 | - `storage__objectStore__storagePath` - set to `content/media/` 153 | - `storage__objectStore__staticFileURLPrefix` - set to `content/media/` 154 | 155 | ### Ghost Configuration 156 | - `url` - Public URL for your Ghost site 157 | - `NODE_ENV` - Set to `production` 158 | - `imageOptimization__resize` - Set to `false` to disable image resizing 159 | - `imageOptimization__srcsets` - Set to `false` to disable srcsets generation 160 | 161 | ## Troubleshooting 162 | 163 | ### Application won't start 164 | - Check logs: `dokku logs your-ghost-app-name` 165 | - Verify all required environment variables are set: `dokku config your-ghost-app-name` 166 | - Ensure your external database is accessible from the Dokku server 167 | 168 | ### Database connection issues 169 | - Verify database credentials and host are correct 170 | - Check that the database server allows connections from your Dokku server 171 | - Confirm the database exists and has proper permissions 172 | 173 | ### S3 storage issues 174 | - Ensure S3 credentials have appropriate permissions 175 | - Verify the bucket exists and is accessible 176 | - Check that the endpoint URL is correct 177 | 178 | ### Health Checks 179 | Ghost will be accessible on port 2368 inside the container. Ensure any firewall rules allow this connection if needed. 180 | 181 | ## Scaling 182 | 183 | Ghost typically runs as a single process, but you can scale horizontally if needed: 184 | 185 | ```bash 186 | dokku ps:scale your-ghost-app-name web=2 187 | ``` 188 | 189 | Note: For Ghost to work properly with multiple instances, you'll need a shared file system for themes and content, which isn't needed with S3 object storage for media files. 190 | 191 | ## Rollback 192 | 193 | If you need to rollback to a previous version: 194 | 195 | ```bash 196 | # List previous releases 197 | dokku releases:list your-ghost-app-name 198 | 199 | # Rollback to a previous release 200 | dokku releases:rollback your-ghost-app-name 201 | ``` 202 | -------------------------------------------------------------------------------- /nginx.conf.sigil: -------------------------------------------------------------------------------- 1 | # This is the template we're using to generate our custom nginx.conf file on Dokku. 2 | # Details of how it works can be found here: https://dokku.com/docs/networking/proxies/nginx/#customizing-the-nginx-configuration 3 | # 4 | # For the Lua functions to work, it is required that Lua be added to Nginx. 5 | # On Ubuntu, this is achieved by running the command below: 6 | # sudo apt install libnginx-mod-http-lua 7 | # 8 | # The central idea of the resulting Nginx conf file is converting URLs containing non-ASCII characters into a format that Ghost understands 9 | # and doing the corresponding redirects. This is necessitated by the fact that Ghost doesn't really have i18n support for URLs and slugs. 10 | # 11 | # Why do all this? So that we can handle Medium URLs on Ghost seamlessly. 12 | # 13 | # This means if a user visits 14 | # https://pesacheck.org/partiellement-faux-le-gouvernement-sénégalais-na-pas-été-dissout-après-les-manifestations-du-19-5a7e55071173 15 | # They get transparently redirected to 16 | # https://pesacheck.org/partiellement-faux-le-gouvernement-s-c3-a9n-c3-a9galais-na-pas--c3-a9t-c3-a9-dissout-apr-c3-a8s-les-manifestations-du-19 17 | # Which is the URL of the post within Ghost 18 | # 19 | # Example conversions: 20 | # 21 | # French 22 | # partiellement-faux-le-gouvernement-sénégalais-na-pas-été-dissout-après-les-manifestations-du-19-5a7e55071173 -> 23 | # partiellement-faux-le-gouvernement-s-c3-a9n-c3-a9galais-na-pas--c3-a9t-c3-a9-dissout-apr-c3-a8s-les-manifestations-du-19 24 | # 25 | # Amharic 26 | # የፈጠራ-ወሬ-የየመን-ሚሳኤል-በእስራኤል-ላይ-ያደረሰውን-ጥቃት-ያሳይሉ-የተባሉት-እነዚህ-ሁለት-ፎቶዎች-የተቀነባበሩ-ናቸው-36aa9b306317 -> 27 | # -e1-8b-a8-e1-8d-88-e1-8c-a0-e1-88-ab--e1-8b-88-e1-88-ac--e1-8b-a8-e1-8b-a8-e1-88-98-e1-8a-95--e1-88-9a-e1-88-b3-e1-8a-a4-e1-88-8d--e1-89-a0-e1-8a-a5-e1-88-b5-e1-88-ab-e1-8a-a4-e1-88-8d- 28 | # 29 | # English 30 | # false-ugandas-education-minister-janet-museveni-has-not-ordered-schools-to-end-their-third-term-4ef52ad31a17 -> 31 | # false-ugandas-education-minister-janet-museveni-has-not-ordered-schools-to-end-their-third-term 32 | # 33 | 34 | 35 | # Determine if the URL needs conversion by checking if it contains non-ASCII characters. 36 | map $uri $needs_conversion { 37 | ~[^\x00-\x7F] "yes"; 38 | default "no"; 39 | } 40 | 41 | server { 42 | listen [::]:80; 43 | listen 80; 44 | server_name {{ .NOSSL_SERVER_NAME }}; 45 | 46 | access_log /var/log/nginx/{{ .APP }}-access.log; 47 | error_log /var/log/nginx/{{ .APP }}-error.log; 48 | underscores_in_headers off; 49 | 50 | client_body_timeout 60s; 51 | client_header_timeout 60s; 52 | keepalive_timeout 75s; 53 | lingering_timeout 5s; 54 | send_timeout 60s; 55 | 56 | # Remove the random ID that Medium appends to post URLs. This effectively does the same redirect recommended by Ghost here: 57 | # https://docs.ghost.org/migration/medium#using-custom-domains 58 | # Note: This rewrite happens before the redirect to HTTPS 59 | rewrite "^/(.*)(-[0-9a-f]{10,12})$" /$1 permanent; 60 | 61 | location / { 62 | return 301 https://$host:443$request_uri; 63 | } 64 | include {{ .DOKKU_ROOT }}/{{ .APP }}/nginx.conf.d/*.conf; 65 | 66 | } 67 | 68 | server { 69 | listen [::]:443 ssl http2; 70 | listen 443 ssl http2; 71 | {{ if .SSL_SERVER_NAME }}server_name {{ .SSL_SERVER_NAME }}; {{ end }} 72 | 73 | access_log /var/log/nginx/{{ .APP }}-access.log; 74 | error_log /var/log/nginx/{{ .APP }}-error.log; 75 | 76 | ssl_certificate {{ .APP_SSL_PATH }}/server.crt; 77 | ssl_certificate_key {{ .APP_SSL_PATH }}/server.key; 78 | ssl_protocols TLSv1.2 TLSv1.3; 79 | ssl_prefer_server_ciphers off; 80 | 81 | client_max_body_size 50m; 82 | client_body_timeout 60s; 83 | client_header_timeout 60s; 84 | keepalive_timeout 75s; 85 | lingering_timeout 5s; 86 | send_timeout 60s; 87 | 88 | location / { 89 | gzip on; 90 | gzip_min_length 1100; 91 | gzip_buffers 4 32k; 92 | gzip_types text/css text/javascript text/xml text/plain text/x-component application/javascript application/x-javascript application/json application/xml application/rss+xml font/truetype application/x-font-ttf font/opentype application/vnd.ms-fontobject image/svg+xml; 93 | gzip_vary on; 94 | gzip_comp_level 6; 95 | 96 | # Check if conversion is needed using the map 97 | access_by_lua_block { 98 | -- First check for Medium ID and remove it before processing 99 | local has_medium_id = string.match(ngx.var.uri, "/(.*)-([%x][%x][%x][%x][%x][%x][%x][%x][%x][%x][%x]?[%x]?)$") 100 | if has_medium_id then 101 | -- Remove Medium ID 102 | local clean_path = string.gsub(ngx.var.uri, "/(.*)-([%x][%x][%x][%x][%x][%x][%x][%x][%x][%x][%x]?[%x]?)$", "/%1") 103 | -- Permanent redirect since the Medium ID doesn't mean much on any other platform 104 | ngx.redirect(clean_path, 301) 105 | return 106 | end 107 | 108 | if ngx.var.needs_conversion == "yes" then 109 | local function convert_string(input_str) 110 | -- Split the input string by hyphens 111 | local parts = {} 112 | for part in string.gmatch(input_str, "([^%-]+)") do 113 | table.insert(parts, part) 114 | end 115 | 116 | -- Process each part (same as above) 117 | local encoded_parts = {} 118 | for i, part in ipairs(parts) do 119 | -- Convert part to UTF-8 byte sequence and encode 120 | local encoded = "" 121 | for j = 1, #part do 122 | local byte = string.byte(part, j) 123 | if byte < 128 and string.match(string.char(byte), "[%w%-_.~]") then 124 | -- Keep safe ASCII characters as is 125 | encoded = encoded .. string.char(byte) 126 | else 127 | -- For all other bytes except the first one, use the -XX format 128 | if #encoded > 0 then 129 | encoded = encoded .. "-" .. string.format("%02x", byte):lower() 130 | else 131 | encoded = encoded .. string.format("%02x", byte):lower() 132 | end 133 | end 134 | end 135 | table.insert(encoded_parts, encoded) 136 | end 137 | 138 | -- Join all parts back with hyphens 139 | local result = table.concat(encoded_parts, "-") 140 | 141 | -- Truncate the URL at 185 characters in case it is longer to match what Ghost expects 142 | if #result > 185 then 143 | result = string.sub(result, 1, 185) 144 | end 145 | 146 | return result 147 | end 148 | 149 | -- Remove leading slash for processing 150 | local original_path = ngx.var.uri 151 | if string.sub(original_path, 1, 1) == "/" then 152 | original_path = string.sub(original_path, 2, -1) 153 | end 154 | 155 | local converted_path = convert_string(original_path) 156 | 157 | -- Redirect to the converted path 158 | -- Only do a temporary redirect allowing us to revert to ASCII URLs in future 159 | ngx.redirect("/" .. converted_path, 307) 160 | return 161 | end 162 | } 163 | 164 | proxy_pass http://{{ .APP }}; 165 | http2_push_preload on; 166 | proxy_http_version 1.1; 167 | proxy_connect_timeout 60s; 168 | proxy_read_timeout 60s; 169 | proxy_send_timeout 60s; 170 | proxy_buffer_size 4k; 171 | proxy_buffering on; 172 | proxy_buffers 8 4k; 173 | proxy_busy_buffers_size 8k; 174 | proxy_set_header Upgrade $http_upgrade; 175 | proxy_set_header Connection $http_connection; 176 | proxy_set_header Host $http_host; 177 | proxy_set_header X-Forwarded-For $remote_addr; 178 | proxy_set_header X-Forwarded-Port $server_port; 179 | proxy_set_header X-Forwarded-Proto $scheme; 180 | proxy_set_header X-Request-Start $msec; 181 | } 182 | include {{ .DOKKU_ROOT }}/{{ .APP }}/nginx.conf.d/*.conf; 183 | } 184 | 185 | upstream {{ .APP }} { 186 | {{ range .DOKKU_APP_WEB_LISTENERS | split " " }} 187 | server {{ . }}; 188 | {{ end }} 189 | } 190 | --------------------------------------------------------------------------------