Run the Copperline cycle-driven Amiga emulator in your browser, served from a single Docker container.
Copperline is written in Rust and compiled to WebAssembly. This image builds the WebAssembly bundle in a Rust build stage and serves the resulting static site with nginx. It boots the bundled open-source AROS ROM out of the box — no Kickstart needed — and configures a stock Amiga 500 (512K chip RAM + 512K trapdoor) by default, switchable to an AGA Amiga 1200 from the page's machine selector.
Looking for the native build instead? This image runs Copperline's in-browser WebAssembly build. A sibling image,
ghcr.io/sidick/copperline-vnc, runs the full native Copperline application inside a container, streamed to your browser over KasmVNC, with persistent configuration and disk/ROM storage in a server-side volume. It is published (alongside an amiberry image) from the amiga-emulation-docker repo and auto-tracks upstream Copperline releases.
Pull the pre-built multi-arch image (linux/amd64 + linux/arm64) from GHCR:
docker run --rm -p 8080:8080 ghcr.io/sidick/copperline:latestOr build it yourself:
docker build -t copperline .
docker run --rm -p 8080:8080 copperlineThen open http://localhost:8080 and click Boot AROS.
The container runs nginx as a non-root user and listens on port 8080 inside
(map it to any host port you like — -p 8080:8080 above).
Use
localhost(or HTTPS), not a LAN IP — see Audio.
Or with Docker Compose:
docker compose up --buildThe emulator runs entirely in your browser, so files reach it through the page, not
the server's filesystem. There are three ways to supply your own files, covered
below with docker and docker compose examples.
Put your disk images on the host and mount that directory at
/usr/share/nginx/html/files; nginx serves it, and the emulator loads disks from it
by URL — no file picker needed.
Create a disks/ directory and drop game.adf into it, then:
# docker
docker run --rm -p 8080:8080 \
-v "$PWD/disks":/usr/share/nginx/html/files:ro \
ghcr.io/sidick/copperline:latest# docker-compose.yml (the bundled compose file already does this)
services:
copperline:
image: ghcr.io/sidick/copperline:latest
ports:
- "8080:8080"
volumes:
- ./disks:/usr/share/nginx/html/files:rodocker compose upThen load the disk any of these ways:
- pick it from the page's DF0 from list… dropdown (mounted files appear there automatically), or
- open
http://localhost:8080/?df0=files/game.adf(a bootable, shareable link), or - click DF0 from URL and enter
files/game.adf, or - browse the listing at
http://localhost:8080/files/.
Supported formats (detected by content): ADF, ADZ, DMS, IPF, SCP — plain or gzip/zip-packed, up to 64 MiB. Disks are always write-protected in the browser.
By default the list dropdowns show every matching file in the folder, named
by filename. To curate — choose which files appear, give them friendly
names, or list files kept in subdirectories — drop an index.json next to
the disks; when present it replaces the scraped listing:
[
{ "name": "Boulder Dash (1 disk)", "url": "boulderdash.adf" },
{ "name": "Lemmings — disk 1 of 2", "url": "lemmings/disk1.adf" },
{ "name": "Lemmings — disk 2 of 2", "url": "lemmings/disk2.adf" },
"workbench13.adf"
]Entries are either a filename (relative to files/) or a {name, url}
object; either way the list is shown alphabetically. One caveat: a manifest
is taken as-is — unlike a scraped listing it is not filtered by file
extension, and both dropdowns read the same folder, so a manifest's entries
appear in the disk and Kickstart lists alike. That makes index.json
a good fit for disk libraries (skip it, or accept the odd entry, if you
also serve ROMs); for separately curated disk and ROM lists, use a custom
shell whose two selects point data-src at different folders, each with
its own manifest.
Put your Kickstart on the host and mount it into /files just like a disk, then load
it with the same-origin ?kick= page parameter:
# docker — mount a directory holding kick13.rom (and any disks)
docker run --rm -p 8080:8080 \
-v "$PWD/files":/usr/share/nginx/html/files:ro \
ghcr.io/sidick/copperline:latestThen open http://localhost:8080/?kick=files/kick13.rom — the Boot button relabels
to your ROM and boots it. You can combine it with a disk in one URL:
?kick=files/kick13.rom&df0=files/game.adf. There's also a Kickstart from URL
button on the page that prompts for a same-origin path.
?kick= is deliberately same-origin only (it can only fetch ROMs the container
already serves — copyrighted images can't be pulled from elsewhere), http(s) only,
and capped at 4 MiB; the core validates the 256/512 KiB ROM size.
You can also load a ROM straight from your machine without mounting anything — click
Load Kickstart… and choose your .rom, or drag a .rom onto the page. Either
way the ROM stays local to your browser and is never uploaded; both work before or
after boot (a pre-boot choice is applied when the machine starts).
The Boot AROS button boots whatever the image serves at
aros/aros-amiga-m68k-rom.bin (main, $F80000) and aros/aros-amiga-m68k-ext.bin
(extended, $E00000). Mount your own matched pair over both files to change what
the button boots — both are fetched and required:
# docker — e.g. swap in a different AROS build
docker run --rm -p 8080:8080 \
-v "$PWD/rom/my-rom.bin":/usr/share/nginx/html/aros/aros-amiga-m68k-rom.bin:ro \
-v "$PWD/rom/my-ext.bin":/usr/share/nginx/html/aros/aros-amiga-m68k-ext.bin:ro \
ghcr.io/sidick/copperline:latest# docker-compose.yml
services:
copperline:
image: ghcr.io/sidick/copperline:latest
ports:
- "8080:8080"
volumes:
- ./rom/my-rom.bin:/usr/share/nginx/html/aros/aros-amiga-m68k-rom.bin:ro
- ./rom/my-ext.bin:/usr/share/nginx/html/aros/aros-amiga-m68k-ext.bin:roThis is meant for swapping in an alternative AROS build, which ships as a matched main + extended ROM pair. A stock Amiga Kickstart is a single ROM with no extended half, so it does not fit this two-file boot path — use the Load Kickstart… picker above for those.
The page's defaults — which machine boots, what's in the drive, whether it
powers on by itself — come from an optional copperline.json served next to
the page (the schema is
upstream's).
The container gives you two ways to provide it:
Set any of these and the container writes copperline.json at startup:
| Variable | copperline.json key |
Values |
|---|---|---|
COPPERLINE_MACHINE |
machine |
A500 (default), A1200 |
COPPERLINE_DF0 |
df0 |
disk URL, e.g. files/demo.adf |
COPPERLINE_KICK |
kick |
ROM path on this site, e.g. files/kick31.rom |
COPPERLINE_JOY |
joy |
off, keys, cd32, touch |
COPPERLINE_FDSPEED |
floppy_speed |
100, 200, 400, 800, turbo |
COPPERLINE_FLOPPY_SOUNDS |
floppy_sounds |
true, false |
COPPERLINE_MONO_AUDIO |
mono_audio |
true, false |
COPPERLINE_SERIAL_URL |
serial_url |
WebSocket gateway URL |
COPPERLINE_SERIAL_RAW |
serial_raw |
true, false |
COPPERLINE_AUTOBOOT |
autoboot |
true, false |
A demo kiosk that boots straight into a mounted disk:
docker run -p 8080:8080 \
-v ./disks:/usr/share/nginx/html/files:ro \
-e COPPERLINE_DF0=files/demo.adf \
-e COPPERLINE_AUTOBOOT=true \
ghcr.io/sidick/copperlineFor full control (or to keep the config in version control), hand-write
copperline.json and mount it; it wins over the environment variables:
docker run -p 8080:8080 \
-v ./copperline.json:/usr/share/nginx/html/copperline.json:ro \
ghcr.io/sidick/copperline- Visitor's own changes on the page always win.
- URL parameters (
?df0=,?kick=,?machine=,?joy=,?fdspeed=) override the config per visit — handy for sharing links. - A bind-mounted
copperline.jsonbeatsCOPPERLINE_*variables. - With neither, the page runs its stock defaults (boot AROS on an A500).
The page is meant to be rebranded and reskinned without forking the image.
Ready-to-adapt compose recipes for common setups (kiosk, disk shelf, BBS
terminal, reskin, custom shell, private hosting) live in
examples/. Three layers, smallest hammer first:
docker run -p 8080:8080 \
-e COPPERLINE_TITLE="Dave's Amiga Corner" \
-e COPPERLINE_SUBTITLE="Insert disk 1 of 11" \
ghcr.io/sidick/copperlinePlain single-line text (it is HTML-escaped for you); sets the page heading, the line under it, and the browser-tab title.
The page's styling funnels through a handful of CSS custom properties, and
theme.css — an empty file in the stock image — loads last, so anything in
it wins. Mount your own to reskin:
docker run -p 8080:8080 \
-v ./theme.css:/usr/share/nginx/html/theme.css:ro \
ghcr.io/sidick/copperline/* theme.css — e.g. a light look with blue accents */
:root {
--bg: #f2f4f8; /* page background */
--panel: #ffffff; /* buttons, inputs, selects */
--line: #c3cadb; /* borders */
--ink: #17202f; /* text */
--ink-mute: #5b6880; /* secondary text, labels */
--accent: #2b6bd9; /* highlights, primary button, links */
}Any further CSS works too — the element ids below are stable.
For a completely different page, mount your own index.html over the
shipped one; the JS glue (try.js and friends) is served by the image and
drives whatever elements it finds. The contract:
Required (try.js binds these unconditionally) — #shell (wrapper around
the display; fullscreen target), #screen (the canvas), #overlay (boot
overlay), #boot (boot button), #load-status (status line), #stat
(performance line), #df0 and #kick (file inputs), #eject, #reset,
#joy, #fullscreen, #vol (range input).
Optional — everything else degrades gracefully or self-builds below the
canvas: #df0url/#kickurl, #df0list/#kicklist (self-filling lists;
data-src names the folder), #machine, #floppy-speed, #pause,
#screenshot, #savestate/#loadstate/#quicksave/#quickload,
#ledbar, #floppy-sounds, #mono-audio,
#serial-url/#serial-connect/#serial-status/#serial-raw,
#bug-report/#bug-report-err. The authoritative list lives in
upstream's browser guide
under "Optional page-shell hooks". A working minimal shell lives in
examples/custom-shell/.
Note: COPPERLINE_TITLE/COPPERLINE_SUBTITLE only know the stock shell's
markers, so with your own page they are ignored (your page is the
branding); a read-only mount is detected and skipped cleanly.
A note before putting a deployment on the open internet. The image itself
contains only open-source software — Copperline (GPL) and the bundled AROS
ROMs (AROS Public License); Kickstart ROMs and most Amiga software are
copyrighted, and anything in /files is supplied by whoever runs the
container. Because the emulator runs in the visitor's browser, any file
the page can load, a visitor can download — there is no way to make a
ROM usable by the emulator but not fetchable, and hiding the directory
listing doesn't change that. So on a publicly reachable deployment, treat
everything mounted into /files as published: either keep such
deployments private, or don't mount files you can't distribute.
Keeping it private means gating who can reach the site at all:
examples/private/ shows both approaches — binding
the port to localhost/LAN (put a TLS-terminating reverse proxy with its
own auth in front to go further), and HTTP basic auth on the whole site
via a mounted nginx config.
AudioWorklet requires a secure context: HTTPS or localhost. Over
http://localhost:8080 audio works. Reaching the container over plain HTTP via a
LAN hostname/IP leaves audio suspended — the emulator still boots and runs, just
silently. For remote/LAN hosting, terminate TLS in front of the container (a reverse
proxy such as Caddy/Traefik, or an nginx TLS cert).
No COOP/COEP headers are needed: the build is single-threaded, so any static host works.
The Copperline git ref is a build argument, defaulting to the latest release tag
this repo has been updated for. Release tags from v0.12.0 onward contain the
browser frontend (crates/copperline-web). Override it with a commit SHA, tag,
or branch:
# Latest main
docker build --build-arg COPPERLINE_REF=main -t copperline .
# A specific commit or release tag (v0.12.0 or later)
docker build --build-arg COPPERLINE_REF=<sha-or-tag> -t copperline .(Or edit COPPERLINE_REF in docker-compose.yml.) The wasm-bindgen CLI version
is parsed from Copperline's own Cargo.toml during the build so it can never drift
from the crate.
The Publish container image workflow builds
a multi-arch (linux/amd64 + linux/arm64) image and pushes it to
ghcr.io/<owner>/<repo>. It is manually triggered: in the repo, go to
Actions → Publish container image → Run workflow and optionally set:
copperline_ref— the Copperline commit SHA, tag, or branch to build.tag— the image tag to publish (defaultlatest); asha-<commit>tag is always added as well.
It authenticates with the built-in GITHUB_TOKEN (no secrets to configure) and
caches the Rust build between runs. The Dockerfile's build stage is pinned to
$BUILDPLATFORM, so the architecture-independent wasm build compiles once on the
native runner instead of being emulated under QEMU for each target arch.
The published package starts private — make it public under the repo's
Packages settings if you want unauthenticated docker pulls.
/usr/share/nginx/html/
├── index.html # minimal page shell (this repo)
├── try.js # emulator page glue (from Copperline)
├── audio-worklet.js # audio output worklet (from Copperline)
├── serial-telnet.js # telnet layer for the serial/BBS bridge (from Copperline)
├── pkg/ # wasm-bindgen bundle (copperline_web.js + .wasm)
├── aros/ # bundled open-source AROS ROMs + licence
└── files/ # VOLUME — your mounted disks/ROMs
- This project — GNU GPL v3 or later (
GPL-3.0-or-later), matching Copperline; seeLICENSE. - Copperline (the emulator built into the image) — GNU GPL v3 or later.
- AROS ROMs — AROS Public License (see
aros/LICENSEin the running container, at/aros/LICENSE). - No Kickstart ROM is included; supply your own dump.