Rootless Docker Setup

Hi,

is there a way to run to run the Ghost server, database, activity pub, and analytics containers as rootless? I’m a little concerned about having this much code running as root, since an exploit to any of these components could result in a complete compromise of my system. I’m especially concerned since the Docker versions of Ghost have repeatedly been left without updates for security vulnerabilities.

I’m willing to set everything from scratch, if it means I can run Ghost and its associated services rootless. Is running Ghost in rootless containers supported?

URL: https://quinndunlap.com (behind HTTP auth while under construction)
Version: v6.21.2

  • Only modification to the default deployment is that Docker deployment is that I commented out lines associated with Caddy, since I already had a different reverse proxy set up.
  • Base OS is Arch Linux, using Docker version 29.3.0, build 5927d80c76, Docker Compose version 5.1.0.

Thank you, I appreciate any help.

1 Like

Ghost team is working on an official docker image which will run rootless. I think they will announce it when they think it’s ready. Here is the current version: Ghost/Dockerfile.production at main · TryGhost/Ghost · GitHub

Thank you, is there anywhere I can subscribe to (like a GitHub issue) to track the progress on this?

@ngeorger has created an alternate Docker image that runs as a “nonroot” user that I’m using.

Work is in progress to automate building new packages when Ghost makes a new release, like Docker Hub does.

Regarding the database, you can run any compatible MySQL container you like.

The analytics and ActivityPub containers I haven’t looked at.

For the analytics container, I don’t see a USER directive, but you could try using a –user flag to force a non-root user:

I don’t see a USER directive in the ActivityPub Dockerfile either:

You could also try running that with the –user flag. As those both appear to Node.js web servers, I see no reason why they couldn’t run rootless.

2 Likes

For the original question: As a complement of @markstos detailed answer, I also suggest running Docker itself in rootless mode. More info in Docker Docs or consider Podman instead of Docker, if you are willing to spend a little more time configuring/maintaining at the benefit of “native” or “out of the box” hardened features.

Thanks @markstos for your support with Ghost on Kubernetes

1 Like

Thank you for all the suggestions. I will wait for the officially supported Docker Compose version to get a rootless version. I will take a look at Podman, but it seems that some of the other software I run doesn’t have official Podman support.

I’m a fan of Podman– it’s what I use myself to run Ghost. But systemd doesn’t support running Podman rootless if you try to run Podman as a rootful systemd service with a User= directive. There’s an extremely long thread about problems and workarounds that people have for that:

You can run Podman as a systemd user service, which has it’s on challenges for management.

But running the container with a –user= directive or with a container that runs as non-root user internally, as Ghost-on-Kubernetes container does (and the official Ghost container reportedly will do) achieves a similar result– that the Ghost process is not running as root.

1 Like

I agree and I felt a bit dumb when you shared your approach with systemd units because I never thought about that method before, and it’s pretty clever.
Oftebly we forget that at the vase, containers are chroots on steroids hahaha

1 Like

In case anyone is interested, this is the compose file I’ve ended up using for my rootless deployment of Ghost under rootful Docker. I’ve tried switching to Podman, but kept having networking performance issues related to Pasta (Podman’s networking implementation), and database connection failures (potentially related to ghost-docker #127).

My config doesn’t use the usual Caddy container, since I use a separate NGINX reverse proxy. If you intend to run Caddy rootless, you might have to give it the CAP_NET_BIND_SERVICE capability, since ports 80 and 443 require privileged access.

services:
  ghost:
    image: ghost:${GHOST_VERSION:-6-alpine}
    restart: always
    user: "uid:gid"
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - NET_RAW
    env_file:
      - .env
    environment:
      NODE_ENV: production
      url: https://${DOMAIN:?DOMAIN environment variable is required}
      admin__url: ${ADMIN_DOMAIN:+https://${ADMIN_DOMAIN}}
      database__client: mysql
      database__connection__host: db
      database__connection__user: ${DATABASE_USER:-ghost}
      database__connection__password: ${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}
      database__connection__database: ghost
      tinybird__tracker__endpoint: https://${DOMAIN:?DOMAIN environment variable is required}/.ghost/analytics/api/v1/page_hit
      tinybird__adminToken: ${TINYBIRD_ADMIN_TOKEN:-}
      tinybird__workspaceId: ${TINYBIRD_WORKSPACE_ID:-}
      tinybird__tracker__datasource: analytics_events
      tinybird__stats__endpoint: ${TINYBIRD_API_URL:-https://api.tinybird.co}
    ports:
      - "127.0.0.1:2368:2368"
      - "[::1]:2368:2368"
    volumes:
      - ${UPLOAD_LOCATION:-./data/ghost}:/var/lib/ghost/content
    depends_on:
      db:
        condition: service_healthy
      tinybird-sync:
        condition: service_completed_successfully
        required: false
      tinybird-deploy:
        condition: service_completed_successfully
        required: false
      activitypub:
        condition: service_started
        required: false
    networks:
      - ghost_network

  db:
    image: mysql:8.0.44@sha256:f37951fc3753a6a22d6c7bf6978c5e5fefcf6f31814d98c582524f98eae52b21
    restart: always
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - NET_RAW
    expose:
      - "3306"
    environment:
      MYSQL_ROOT_PASSWORD: ${DATABASE_ROOT_PASSWORD:?DATABASE_ROOT_PASSWORD environment variable is required}
      MYSQL_USER: ${DATABASE_USER:-ghost}
      MYSQL_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}
      MYSQL_DATABASE: ghost
      MYSQL_MULTIPLE_DATABASES: activitypub
    volumes:
      - ${MYSQL_DATA_LOCATION:-./data/mysql}:/var/lib/mysql
      - ./mysql-init:/docker-entrypoint-initdb.d
    healthcheck:
      test: mysqladmin ping -p$$MYSQL_ROOT_PASSWORD -h 127.0.0.1
      interval: 1s
      start_period: 30s
      start_interval: 10s
      retries: 120
    networks:
      - ghost_network

  traffic-analytics:
    image: ghost/traffic-analytics:1.0.160@sha256:b4226f94192b7e0c2051dadd45bf5190a03bd2336e50e6c05e9de0747cadc2d8
    restart: always
    user: "uid:gid"
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - NET_RAW
    expose:
      - "3000"
    volumes:
      - traffic_analytics_data:/data
    environment:
      NODE_ENV: production
      PROXY_TARGET: ${TINYBIRD_API_URL:-https://api.tinybird.co}/v0/events
      SALT_STORE_TYPE: ${SALT_STORE_TYPE:-file}
      SALT_STORE_FILE_PATH: /data/salts.json
      TINYBIRD_TRACKER_TOKEN: ${TINYBIRD_TRACKER_TOKEN:-}
      LOG_LEVEL: debug
    profiles: [analytics]
    networks:
      - ghost_network

  activitypub:
    image: ghcr.io/tryghost/activitypub:1.1.0@sha256:39c212fe23603b182d68e67d555c6b9b04b1e57459dfc0bef26d6e4980eb04d1
    restart: always
    user: "uid:gid"
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - NET_RAW
    expose:
      - "8080"
    volumes:
      - ${UPLOAD_LOCATION:-./data/ghost}:/opt/activitypub/content
    environment:

      NODE_ENV: production
      MYSQL_HOST: db
      MYSQL_USER: ${DATABASE_USER:-ghost}
      MYSQL_PASSWORD: ${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}
      MYSQL_DATABASE: activitypub
      LOCAL_STORAGE_PATH: /opt/activitypub/content/images/activitypub
      LOCAL_STORAGE_HOSTING_URL: https://${DOMAIN}/content/images/activitypub
    depends_on:
      db:
        condition: service_healthy
      activitypub-migrate:
        condition: service_completed_successfully
    profiles: [activitypub]
    networks:
      - ghost_network

  tinybird-login:
    build:
      context: ./tinybird
      dockerfile: Dockerfile
    working_dir: /home/tinybird
    command: /usr/local/bin/tinybird-login
    user: "uid:gid"
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - NET_RAW
    volumes:
      - tinybird_home:/home/tinybird
      - tinybird_files:/data/tinybird
    profiles: [analytics]
    networks:
      - ghost_network
    tty: false
    restart: no

  tinybird-sync:

    image: ghost:${GHOST_VERSION:-6-alpine}
    command: >
      sh -c "
        if [ -d /var/lib/ghost/current/core/server/data/tinybird ]; then
          rm -rf /data/tinybird/*;
          cp -rf /var/lib/ghost/current/core/server/data/tinybird/* /data/tinybird/;
          echo 'Tinybird files synced into shared volume.';
        else
          echo 'Tinybird source directory not found.';
        fi
      "
    user: "uid:gid"
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - NET_RAW
    volumes:
      - tinybird_files:/data/tinybird
    depends_on:
      tinybird-login:
        condition: service_completed_successfully
    networks:
      - ghost_network
    profiles: [analytics]
    restart: no

  tinybird-deploy:
    build:
      context: ./tinybird
      dockerfile: Dockerfile
    working_dir: /data/tinybird
    command: >
      sh -c "
        tb-wrapper --cloud deploy
      "
    user: "uid:gid"
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - NET_RAW
    volumes:
      - tinybird_home:/home/tinybird
      - tinybird_files:/data/tinybird
    depends_on:
      tinybird-sync:
        condition: service_completed_successfully
    profiles: [analytics]
    networks:
      - ghost_network
    tty: true

  activitypub-migrate:
    image: ghcr.io/tryghost/activitypub-migrations:1.1.0@sha256:b3ab20f55d66eb79090130ff91b57fe93f8a4254b446c2c7fa4507535f503662
    user: "uid:gid"
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - NET_RAW
    environment:
      MYSQL_DB: mysql://${DATABASE_USER:-ghost}:${DATABASE_PASSWORD:?DATABASE_PASSWORD environment variable is required}@tcp(db:3306)/activitypub
    networks:
      - ghost_network
    depends_on:
      db:
        condition: service_healthy
    profiles: [activitypub]
    restart: no

volumes:
  caddy_data:
  caddy_config:
  tinybird_files:
  tinybird_home:
  traffic_analytics_data:

networks:
  ghost_network:

I’ve had to change the perms on the data directory to make data/ghost to be owned by the user I have the Ghost container running under; and to change the data/mysql directory to be owned by 999:adm.

If anyone wants to upstream my changes or findings into Ghost, or the Ghost docs, you are welcome to do so.

1 Like