Instructure Canvas LMS, built for local development use only. Not a single image service.
1.4K
Admin Username: [email protected]
Admin Password: password
Admin API Key - depends on which image you use
Postgres Username: postgres
Postgres Password: sekret
Postgres Database Name: canvas_development
The docker compose snippet below should bring up a Canvas instance accessible at http://localhost:9100/
NOTE: For ARM users (e.g.: Apple Silicon Macs), use the arm variant of the image tags, e.g.: lthub/canvas-dev-only:release.2026-01-28.618.arm
This example expects you to copy the database dump from within the Canvas image in /usr/src/app/dbinit/ to a local directory docker/dev/canvas/. The database dump sql files are executed when postgres starts up with an empty database to initialize Canvas. Example command to copy the database base to host:
docker create --name tmpCanvas lthub/canvas-dev-only:release.2026-01-28.618
mkdir -p docker/dev/canvas
docker cp tmpCanvas:/usr/src/app/dbinit/. docker/dev/canvas/
docker rm -f tmpCanvas
Example docker-compose.yml file below for running this dev instance.
# Only for dev use
services:
canvas.postgres:
image: pgvector/pgvector:pg14
volumes:
- canvasPostgresData:/var/lib/postgresql/data
- ./docker/dev/canvas:/docker-entrypoint-initdb.d
healthcheck: # no built-in healthcheck in the image, so we specify one
test: ["CMD-SHELL", "pg_isready -U postgres -h localhost -p 5432"]
start_period: 2m # ignore failures in the initial 2 minutes
environment:
POSTGRES_PASSWORD: sekret
canvas: &CANVAS
image: lthub/canvas-dev-only:release.2026-01-28.618
ports:
- 9100:80
depends_on:
canvas.postgres:
# if starting from scratch, postgres db needs to populated, which can
# take some time, so we need to wait until postgres is healthy
condition: service_healthy
canvas.redis:
condition: service_started
volumes:
- api_docs:/usr/src/app/public/doc/api
- brandable_css_brands:/usr/src/app/app/stylesheets/brandable_css_brands
- bundler:/home/docker/.bundle/
- canvas-docker-gems:/home/docker/.gem/
- js-utils_es:/usr/src/app/packages/js-utils/es
- js-utils_lib:/usr/src/app/packages/js-utils/lib
- js-utils_node_modules:/usr/src/app/packages/js-utils/node_modules
- locales:/usr/src/app/config/locales/generated
- log:/usr/src/app/log
- node_modules:/usr/src/app/node_modules
- pacts:/usr/src/app/pacts
- public_dist:/usr/src/app/public/dist
- reports:/usr/src/app/reports
- styleguide:/usr/src/app/app/views/info
- tmp:/usr/src/app/tmp
- translations:/usr/src/app/public/javascripts/translations
- yardoc:/usr/src/app/.yardoc
- yarn-cache:/home/docker/.cache/yarn
environment:
VIRTUAL_HOST: .canvas.docker
CANVAS_DATABASE_HOST: canvas.postgres
CANVAS_REDIS_HOST: canvas.redis
HTTPS_METHOD: noredirect
POSTGRES_PASSWORD: sekret
ENCRYPTION_KEY: facdd3a131ddd8988b14f6e4e01039c93cfa0160
RAILS_ENV: development
canvas.job:
<<: *CANVAS
command: bundle exec script/delayed_job run
attach: false # job is very noisy, so we hide logs for it by default
ports: []
canvas.redis:
image: redis:alpine
# Inside the docker network, canvas is served on port 80. When we expose
# canvas to external traffic however, we serve canvas on port 9100. This
# mismatch means that when app talks directly to canvas using the external
# url (http://localhost:9100), it doesn't work because app is also inside
# the docker network and needs to use port 80. To workaround this, socat will
# redirect internal docker traffic to port 9100 to canvas port 80.
canvas.socat:
image: alpine/socat:1.8.0.3
depends_on:
- canvas
command: "TCP-LISTEN:9100,fork,reuseaddr TCP:canvas:80"
volumes:
canvasPostgresData: {}
api_docs: {}
brandable_css_brands: {}
bundler: {}
canvas-docker-gems: {}
i18nliner_node_modules: {}
js-utils_es: {}
js-utils_lib: {}
js-utils_node_modules: {}
k5uploader_es: {}
k5uploader_lib: {}
k5uploader_node_modules: {}
locales: {}
log: {}
node_modules: {}
pg_data: {}
pacts: {}
public_dist: {}
reports: {}
styleguide: {}
tmp: {}
translations: {}
yardoc: {}
yarn-cache: {}
Refer to 'Expected Credentials' section above for URL and admin credentials.
Note that I use the hosts file to map the domain 'canvas.docker' to 127.0.0.1, so I can use http://canvas.docker:9100/ to access Canvas. Some parts of the docker compose file uses this canvas.docker domain, if you encounter unexpected hostname issues when using http://localhost:9100, it might be due to this.
To build a new version, first checkout the Canvas LMS repo and switch to a release tag. The release tags are dated, avoid releases that have not yet been deployed and be cautious of the current deployed release. Future release tags might not build properly, and even the currently deployed tags might have bugs that's being patched. For example, if we're currently on 2025-07-02 release tag, both the current 2025-07-02 tags and the future 2025-07-16 tags might have issues, and Instructure is probably still adding commits to them. It might be safer to use the release before 2025-07-02, which is 2025-06-18.
Once you're on a release tag, run the Canvas setup script manually ./script/docker_dev_setup.sh to get a working running Canvas. The Canvas setup script unfortunately does not use Bundler's deployment build option, so it might try to modify the lockfile and run into a permission issue. This is one factor in why it can be difficult to reproduce Canvas builds. See Troubleshooting section for more details.
Once you get docker_dev_setup.sh to complete successfully, then you can build a new image using Canvas' own Dockerfile.production, with the following modifications:
Editing config/redis.yml to replace redis://redis with redis://<%= ENV.fetch('CANVAS_REDIS_HOST', 'redis') %> so the redis host can be configured via env vars.
Create config/environments/development-local.rb with the content below. This file disables Canvas not allowing requests to local IPs as a security measure. Without it, local development has issues (e.g.: course > people ajax loading, grabbing JWKS from a locally running app):
Rails.application.configure do
# remove blocking local ips in dev (in particular 10.0.0.1/8,
# 192.168.0.0/16), this breaks ajax loading (e.g.: course > people) and makes
# Canvas unable to get JWKS from other docker compose services
CanvasHttp.blocked_ip_ranges = []
end
Editing config/database.yml to replace <%= ENV.fetch('POSTGRES_PASSWORD') %> with <%= ENV.fetch('POSTGRES_PASSWORD', 'postgres') %>, this fix a build issue that complains POSTGRES_PASSWORD is null.
We want to use the prod Dockerfile so we have the compiled static dependencies and don't need to run a separate webpack container for them.
To build the image, docker-compose.override.yml was edited to add an 'app' section for building the image as follows:
app:
build:
context: .
dockerfile: Dockerfile.production
image: lthub/canvas-dev-only:release.2025-04-23.348
environment:
VIRTUAL_HOST: .canvas.docker
HTTPS_METHOD: noredirect
POSTGRES_PASSWORD: sekret
ENCRYPTION_KEY: facdd3a131ddd8988b14f6e4e01039c93cfa0160
The image can then be build using docker compose build app and pushed to
Dockerhub using docker compose push app. Remember to update the image tag as
necessary.
Database dump was taken inside the Postgres container and copied to dbinit/
inside the Canvas image. You will need to mount these sql files to your own
Canvas's Postgres init scripts directory in order to properly initialize the
Canvas database. The database dump commands:
pg_dumpall -U postgres -W --globals-only --file=01_globals.sql
pg_dump -U postgres -W --create canvas_development > 02_canvas_development.sql
pg_dump -U postgres -W --create canvas_test > 03_canvas_test.sql
Edit the 01_globals.sql file to remove the creation of the role postgres as that'll be automatically created by the postgres container.
We won't use the canvas_test database, it's included to make migration to newer Canvas versions easier. Instructure's docker_canvas_setup.sh script's db migration step expects both canvas_development and canvas_test databases to exist.
The Canvas postgres service in your app's docker-compose.yml should look something like this, we need the pgvector plugin, so we're using their image:
canvas.postgres:
image: pgvector/pgvector:pg14
volumes:
- canvasPostgresData:/var/lib/postgresql/data
- ./path/to/canvas/sql:/docker-entrypoint-initdb.d
environment:
POSTGRES_PASSWORD: sekret
[email protected] has Integration ID set (so apps requiring PUID don't complain)There is a monthly cron job (1st of every month) that gets executed to rotate the LTI keys, it is configured in: config/initializers/periodic_jobs.rb. The job is Lti::KeyStorage.rotate_keys. We can find the job this creates in the delayed_jobs table:
SELECT * FROM "delayed_jobs" where handler like '%rotate_keys%';
We can change the 'run_at' field so it'll run sooner. Not sure if needed, but best to restart Canvas after changing the db in case that's needed for the change to be picked up. Warning: The rotate keys job enforces 1 hour of separation between rotates, so it'll do nothing if you've already rotated keys once in the past hour. There are 3 old keys to rotate out, so this requires at least 3 hours.
Template:
UPDATE "delayed_jobs" SET run_at = '<INSERT DATETIME>' WHERE id=<INSERT ID>;
Example:
UPDATE "delayed_jobs" SET run_at = '2025-11-17 08:58:00' WHERE id=2793;
File Permission Issue While Building Image
You might get a permission error about writing to the Gemfile.lock or similar. Instructure uses a user/group with a uid/gid of 9999 to run all their containers. So one way we can sidestep the permission issue is to set all Canvas files to be owned by this Instructure docker user/group. In my case, I created a instructure-docker group with a gid of 9999 and set the entire Canvas source directory to be owned by that group.
Alternatively, edit scripts/docker_dev_setup.sh and comment out the CANVAS_SKIP_DOCKER_USERMOD declaration:
#CANVAS_SKIP_DOCKER_USERMOD='true'
This will instead change the Instructor docker user's uid to your own uid.
Unable to migrate to newer database
If the failure is on 20251008075319_add_access_ignite_agent_permission.rb or
similar AI permission migration, then the issue may be the custom roles and
permissions copied over from Canvas prod. Might be a configuration thing, but
the AI migrations seem to be expecting hardcoded roles that doesn't exist for
us, even when creating the Canvas database from scratch.
Edit dbinit/02_canvas_development.sql and delete the section that copies all
the UBC roles and role overrides. The role overrides section should start with
COPY public.role_overrides. The roles section should start with COPY public.roles, as of 2025-10-08, the roles with id 1 to 16 are the default
roles, delete the non-default roles (id 17 and up). Reset the database with the
updated sql file and try the migration again.
Unfortunately, this does require recopying the roles and permissions from prod Canvas again.
Content type
Image
Digest
sha256:4f60d7348…
Size
2.8 GB
Last updated
7 months ago
docker pull lthub/canvas-dev-only:release.2026-01-28.626.arm