Run a full digital-IC design flow inside reproducible, isolated containers — without polluting your host, and without giving up the modern Linux toolchain you already like.
- Overview
- Why Two Containers?
- Architecture at a Glance
- Repository Layout
- Prerequisites
- Quick Start
- Detailed Usage
- How EDA Commands Cross the Container Boundary
- Configuration Reference (
eda_config.toml) - Per-Project Environment Variables
- GUI / X11 Applications
- Development
- Troubleshooting
- Default Credentials
Industrial EDA tools (Synopsys VCS / Design Compiler / PrimeTime, Cadence Innovus / Xcelium, Verdi, etc.) are certified on Red Hat Enterprise Linux and its derivatives, and they tend to lag the rest of the Linux ecosystem. At the same time, the open-source toolchain a typical digital-IC engineer wants — modern compilers, fish, pre-commit, recent Python, uv, cmake, meson, gtkwave, … — is much easier to consume on Ubuntu.
This project gives you both, side by side, with no host-level installation:
- An Ubuntu 24.04 container for editing, scripting, version control, building and general development.
- A Rocky Linux 9.3 container that hosts the EDA tools, managed via Environment Modules.
- A small wrapper layer (
pyproxy+eda_proxy.py) on the Ubuntu side that makes EDA binaries look local, while the real work happens over SSH on the Rocky side. - A per-project Docker volume so each tape-out / experiment has its own isolated workspace, and multiple projects can run concurrently.
End result: you cd into your project on Ubuntu, type dc_shell or vcs -full64 like you would on a workstation, and the wrapper takes care of selecting the right module load, forwarding the environment, transporting the working directory, and routing X11 back to your host.
| Concern | Ubuntu container | Rocky container |
|---|---|---|
| Modern dev toolchain (compilers, fish, git, python 3.14, pre-commit) | First-class | Painful on RHEL 9 |
| EDA vendor support (Synopsys / Cadence) | Unsupported, license-check fragile | Officially supported |
| Editing / scripting / build orchestration | Yes | No |
Actually running vcs / dc_shell / innovus |
No | Yes |
Splitting the responsibilities along this seam keeps each image small and stable, and lets you upgrade the Ubuntu side aggressively without disturbing the EDA side.
host (Linux + X server)
│
├── /tmp/.X11-unix ──► both containers (X11 forwarding)
├── /cad (EDA installs) ──► rocky:/cad (read-only)
├── /etc/hosts ──► rocky:/etc/hosts (license daemons)
├── /sys ──► rocky:/sys (read-only, dc_shell fix)
└── docker volume <proj>_workspace ──► both containers at /home/aislab/workspace
(this is how the two sides share files)
ubuntu_server (hostname: ubuntu) rocky_server (hostname: rocky)
┌────────────────────────────────┐ ┌────────────────────────────────┐
│ user: aislab, shell: fish │ │ user: aislab, shell: tcsh │
│ /usr/local/bin/eda_proxy.py │ ssh │ sshd on :22 │
│ symlinks: vcs, dc_shell, ... │ ───────► │ envmodules + EDA toolchain │
│ CURRENT_PROJECT=<name> │ │ ml vcs / ml synthesis / ... │
└────────────────────────────────┘ └────────────────────────────────┘
│ bridge network "internal" │
└─────────────────────────────┘
Key points:
- The two containers share
/home/aislab/workspacethrough a named, per-project Docker volume. Anything you do in one is visible in the other immediately — there is no rsync, no NFS, no copy step. - The Ubuntu side never installs EDA tools. It only ships symlinks that resolve to a Python wrapper, which
ssh's into the Rocky side. - The Rocky side runs
sshdas PID 1 and is the only service listening on the internal docker network. - SSH is passwordless from Ubuntu → Rocky thanks to a key pair you generate once before the first build.
.
├── compose.yaml # Docker Compose definition for both services
├── attach.sh # Interactive project manager (launch / attach / teardown)
├── ubuntu/
│ ├── Dockerfile # Ubuntu 24.04 image (dev side)
│ └── ssh_config # Defines the `eda` SSH host alias -> rocky container
├── rocky/
│ └── Dockerfile # Rocky Linux 9.3 image (EDA side, runs sshd)
├── pyproxy/ # Python package: builds the remote SSH payload
│ ├── builder.py # Assembles `ml <module>; cd <cwd>; <tool> <args>`
│ ├── config.py # Typed dataclasses for eda_config.toml
│ ├── executors.py # subprocess wrapper around ssh
│ ├── shell_strategies.py # Tcsh / Bash command-chaining strategies
│ └── __init__.py
├── script/
│ ├── eda_proxy.py # The actual entry point invoked via symlinks
│ ├── eda_config.toml # Tools, module names, project env passthrough
│ └── setup_symlinks.py # Generates /usr/local/bin/<tool> -> eda_proxy.py
├── tests/
│ └── test_builder.py # pytest unit tests for payload construction
├── secrets/ # SSH key pair shared between the two images (gitignored)
├── pyproject.toml # uv / pytest / ruff / ty configuration
└── eda-with-docker.png # Architecture diagram shown at the top
On the host you need:
- Docker Engine and the Docker Compose v2 plugin (
docker compose ..., notdocker-compose). - A running X server if you intend to launch GUI tools (
gtkwave,verdi,innovus, …). On Linux desktops this is normally already the case; you only needxhostavailable, whichattach.shcalls for you. - Your licensed EDA tool installs available at
/cadon the host (Synopsys / Cadence / CBDK trees). The compose file mounts/cadread-only into the Rocky container. - Your license server hostnames resolvable. The compose file mounts the host's
/etc/hostsread-only into the Rocky container so that license daemon lookups continue to work the same way they do on the host.
# 1. Generate the SSH key pair the two images share (only the first time)
mkdir -p secrets
ssh-keygen -f ./secrets/id_ed25519 -N ''
# 2. Launch the interactive manager and pick "Launch new project"
./attach.shattach.sh will build the two images on first use, create a dedicated Docker volume for the project, bring the compose stack up under a project-scoped name, and drop you into a fish shell inside the Ubuntu container. From there:
ssh eda # hop to the Rocky container (passwordless, alias from ubuntu/ssh_config)
vcs -full64 ... # or just run an EDA tool directly — the wrapper handles SSH for youBefore the first build, generate an ed25519 key pair under ./secrets/. The build copies the private key into the Ubuntu image and the public key into the Rocky image's ~/.ssh/authorized_keys, so SSH from Ubuntu to Rocky never asks for a password.
mkdir -p secrets
ssh-keygen -f ./secrets/id_ed25519 -N ''secrets/ is in .gitignore and must stay there.
attach.sh is the only command you need day-to-day. It is an interactive menu with three actions:
=== EDA Docker Manager ===
1) Launch new project
2) Attach to existing project
3) Turn down a project
4) Quit
It validates $DISPLAY, calls xhost +local:docker so the containers can talk to your X server, builds images on demand, and wires up per-project compose names and volumes.
Choosing "Launch new project" prompts for a project name (e.g. cva6, tape_out_q3, …) and then:
- Builds
eda_docker_ubuntuandeda_docker_rockyif they do not exist yet. - Creates a Docker named volume
<project>_workspace. - Brings the compose stack up scoped to that project name:
This means you can run multiple projects in parallel; each gets its own pair of containers (
PROJECT_VOLUME=<project>_workspace docker compose -p <project> up -d
<project>-ubuntu_server-1,<project>-rocky_server-1) and its own workspace volume. - Attaches you to the Ubuntu container with
fish, withDISPLAYforwarded.
Inside the Ubuntu container, your working directory is /home/aislab/workspace and it is shared live with the Rocky container at the same path. Put your design files there.
Choosing "Attach to existing project" lists every container whose name matches *ubuntu* (running or exited), lets you pick one, starts it if needed, and docker exec's you into fish. Use this after a reboot, or to open a second terminal into the same project.
Choosing "Turn down a project" lists currently running projects (derived from the running *-ubuntu_server-1 containers), lets you pick one, and runs docker compose down scoped to that project. The named volume is preserved — you can attach.sh → "Attach to existing project" later and pick up where you left off, or run another launch with the same name to bring it back up. To delete a project's data permanently, follow up with docker volume rm <project>_workspace.
The neat part of this repo is that you do not have to ssh eda manually every time you want to run an EDA tool. You can stay in the Ubuntu container and run dc_shell, vcs, innovus etc. as if they were installed locally. Three pieces make this work.
pyproxy/ is a small Python package responsible for assembling the command that will run on the Rocky side. It is split into single-responsibility modules so the logic stays testable:
config.py— Strongly-typed dataclasses (PathConfig,ConnectionConfig,ToolConfig,EnvironmentConfig,EDAConfig) and aload()classmethod that readseda_config.toml. On any parse error it falls back to safe defaults.shell_strategies.py— Strategy pattern for shell syntax.TcshStrategyemitssetenv X valand joins with;;BashStrategyemitsexport X=valand joins with&&. Rocky'saislabuser defaults totcsh, soTcshStrategyis the default.builder.py—RemotePayloadBuildercomposes the three pieces of a remote command:with_passthrough_env()— looks at$CURRENT_PROJECT, finds the matching[environment.<project>]entry in the config, and emitssetenv/exportlines for each variable listed underpassthrough.with_envmodules_enable(tool_name)— looks the tool up in[eda_tools.module_map]and prepends the correspondingml <module>so the right Synopsys / Cadence environment is initialised.build(tool_name, args)— appendscd $PWDand finally the tool invocation, then asks the shell strategy to chain them.
executors.py—SSHExecutorinvokessshwith the options from[connection], allocates a TTY when appropriate, and propagates the remote exit code (including 130 on Ctrl-C).
script/eda_proxy.py is the single script that every EDA "command" on the Ubuntu side actually runs. It:
- Loads
eda_config.tomlfrom/usr/local/bin/eda_config.toml. - Reads
sys.argv[0]to discover which tool name it was invoked as (this is why it must be called through a symlink — running it directly is rejected). - Builds the remote payload through
RemotePayloadBuilderwithTcshStrategy. - Hands the payload to
SSHExecutor, whichssh aislab@eda <payload>'s it and forwards the exit code.
Set EDA_PROXY_DEBUG=1 in the environment to see the exact ssh invocation on stderr.
There is one special tool name: delegate. Calling delegate <anything> bypasses the ml step entirely and just runs <anything> on the Rocky side. This is the escape hatch for commands that are not in [eda_tools] (for example, running a Makefile that internally sequences several tools).
At image build time, script/setup_symlinks.py runs inside the Ubuntu image and, for every entry in [eda_tools.commands], creates a symlink at /usr/local/bin/<command> that points to eda_proxy.py. It also always creates the delegate symlink. Because each symlink shares the same target, eda_proxy.py can recover the intended tool name from sys.argv[0]. Adding a new EDA tool is therefore a one-line change to the TOML — rebuild the Ubuntu image and the symlink appears.
script/eda_config.toml is the single source of truth that the Ubuntu image bakes in. Schema:
[path]
wrapper = "/usr/local/bin/eda_proxy.py" # the entry point all symlinks point to
bin_dir = "/usr/local/bin" # where the per-tool symlinks live
[connection]
remote_host = "eda" # SSH alias defined in ubuntu/ssh_config
remote_user = "aislab"
ssh_options = [ # appended verbatim to the `ssh` command line
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=QUIET",
]
[eda_tools]
commands = [ # one symlink per name will be created on Ubuntu
"vcs", "verdi", "nWave",
"dv", "dc_shell",
"icc_shell", "icc2_shell", "fc_shell",
"pt_shell",
"fm", "fm_shell",
"vc_static_shell", "vcf",
"xrun",
"innovus",
]
[eda_tools.module_map] # tool name -> envmodules module to `ml` first
vcs = "vcs"
verdi = "verdi"
nWave = "verdi"
dv = "synthesis"
dc_shell = "synthesis"
icc_shell = "icc"
icc2_shell = "icc2"
fc_shell = "fc"
pt_shell = "primetime"
fm = "formality"
fm_shell = "formality"
vc_static_shell = "vc-formal"
vcf = "vc-formal"
xrun = "xcelium"
innovus = "innovus"
# Per-project environment variable passthrough (see next section)
[environment.cva6]
passthrough = ["RISCV", "NUM_JOBS", "DV_SIMULATORS"]EDA flows often rely on a fistful of environment variables that are project-specific (RISC-V toolchain root, simulator selection, parallel-job count, …). To avoid hard-coding them or polluting every shell, the proxy uses a single switch:
- In the Ubuntu container, export
CURRENT_PROJECT=<name>(e.g. in your project'sdirenv/setup.sh). - In
eda_config.toml, declare which variables to forward for that project:[environment.cva6] passthrough = ["RISCV", "NUM_JOBS", "DV_SIMULATORS"]
- When you now run any EDA tool, the wrapper prepends
setenv RISCV …; setenv NUM_JOBS …; setenv DV_SIMULATORS …to the remote payload, but only the variables listed for the active project. Other shell variables stay on the Ubuntu side.
If CURRENT_PROJECT is unset, or no [environment.<name>] section exists, no variables are forwarded — the EDA tool gets a clean Rocky environment plus whatever ml <module> set up.
Both containers receive DISPLAY from the host and bind-mount /tmp/.X11-unix read-write, and attach.sh calls xhost +local:docker to grant access. This means GUI EDA tools (verdi, dve, innovus, gtkwave, feh, …) work transparently:
- Tools launched from the Ubuntu side via a wrapper symlink run on Rocky but their windows render on your host.
- The Rocky
sshdis configured withX11Forwarding yesandAcceptEnv DISPLAY, so manualssh eda <gui-tool>invocations also display correctly.
On a remote / headless host you will need to set up X forwarding to your laptop separately (e.g. ssh -X to the host, then run attach.sh there); the in-container side is already configured.
The Python wrapper code is set up to be developed with uv, and its quality gates are pytest, ruff, and ty.
uv sync # create .venv and install runtime + dev deps
source .venv/bin/activate # optional — `uv run` also workspyproject.toml pins Python 3.14 and declares pytest as the runtime dep plus pytest, ruff, and ty as dev deps. The lockfile is uv.lock.
uv run pytest # runs tests/test_builder.pytests/test_builder.py exercises RemotePayloadBuilder end-to-end with both TcshStrategy and BashStrategy, plus the special delegate mode. It uses monkeypatch to simulate CURRENT_PROJECT and the forwarded env vars, asserting that the assembled payload contains the right setenv / export, the right ml <module>, the right cd, and the right tool invocation.
uv run ruff check . # lint
uv run ruff format . # auto-format
uv run ty check # static type checkingError: DISPLAY is not set.—attach.shaborts when$DISPLAYis empty. Export it (or usessh -Xif you're remote) before running the script.ssh: connect to host rocky port 22: Connection refused— the Rocky container is not running. Useattach.sh→ "Attach to existing project" to start it, or checkdocker compose -p <project> ps.- EDA tool fails with a license error — confirm
/etc/hostson the host resolves your license server, and that/cadon the host actually contains the tool installation. Both are bind-mounted into the Rocky container at startup. dc_shellcomplains about/sys— the compose file already mounts/sysread-only into Rocky to work around this; if you customised the file, make sure that mount is still present.- GUI window doesn't appear — make sure
xhost +local:dockeractually ran (it's done byattach.sh), and that you launched a GUI-capable tool with$DISPLAYset inside the container. - Want to see what's actually being SSH'd? —
export EDA_PROXY_DEBUG=1in the Ubuntu container before invoking a tool. The wrapper will print the fullsshcommand line to stderr. - Permission errors creating symlinks at build time —
setup_symlinks.pyruns as root during image build; if you adapted it to run inside a running container, make sure the user has write access tobin_dir.
For convenience, both containers ship with the same demo credentials:
- User:
aislab - Password:
1234
These are fine inside an isolated, single-user dev setup but are obviously not suitable for a multi-tenant or networked environment. Change them in both Dockerfiles before exposing the containers beyond your machine.
