Ubuntu에서 자체 CA 인증서 추가 및 신뢰 설정 방법
Ubuntu 시스템에서 자체 CA 루트 인증서를 /usr/local/share/ca-certificates에 설치하고 update-ca-certificates 명령으로 신뢰 저장소를 갱신하는 방법을 설명합니다.
Ubuntu 신뢰 저장소에 자체 CA 추가하기
Ubuntu 신뢰 저장소에 자체 CA를 추가하려면 루트 인증서를 /usr/local/share/ca-certificates/ 디렉터리에 .crt로 끝나는 이름으로 복사한 뒤, sudo update-ca-certificates 명령을 실행합니다. CA(인증 기관)는 다른 인증서에 서명할 수 있는 권한을 가진 키 쌍입니다. 시스템이 루트 인증서를 신뢰하게 되면 해당 루트가 서명한 모든 인증서가 승인되므로, 자체 서비스 간의 HTTPS 통신에서 검증 실패가 발생하지 않습니다.
이 가이드는 openssl를 사용하여 오프라인에서 전체 체인을 구축합니다. 루트 키와 루트 인증서를 생성하고, 서버용 리프 인증서를 하나 발급한 뒤, 루트 인증서를 설치하여 동일한 검증 명령의 결과가 어떻게 바뀌는지 확인합니다. 설치 전후의 검증 과정을 비교하는 것이 이 작업의 핵심이며, 이를 통해 설치가 결과에 미치는 영향을 직접 확인할 수 있습니다.
Ubuntu 24.04는 기본 이미지에 OpenSSL 3과 ca-certificates 패키지가 포함되어 있으므로, 별도로 설치할 항목은 없습니다(2026년 8월 기준 확인 완료).
자체 CA를 운영해야 하는 시점은 언제인가?
Let's Encrypt와 같은 공인 CA는 공개 DNS에 등록된 도메인 이름과 외부에서 접근 가능한 서버를 필요로 합니다. 내부용 도메인 이름은 여기에 해당하지 않습니다. 사설 네트워크의 데이터베이스나 터널에 바인딩된 관리자 패널은 공인 인증서를 발급받을 수 없으며, 인증서 발급만을 위해 이를 인터넷에 노출해서도 안 됩니다.
Ubuntu에서의 자체 서명 인증서는 단일 호스트 문제만 해결합니다. 모든 클라이언트가 해당 인증서를 각각 신뢰해야 하며, 새로운 호스트가 추가될 때마다 동일한 작업을 반복해야 합니다. 사설 CA를 사용하면 신뢰 결정 단계를 한 단계 위로 올릴 수 있습니다. 클라이언트는 루트 인증서를 한 번만 신뢰하면 되며, 이후 루트가 서명하는 모든 인증서는 자동으로 신뢰받게 됩니다. 여기에는 아직 존재하지 않는 호스트를 위한 인증서도 포함됩니다.
물론 비용은 발생합니다. 루트 키는 제약 조건 내에서 무엇이든 서명할 수 있으므로, ca.key을 읽을 수 있는 사람은 누구든 귀하의 머신이 수락할 인증서를 발급할 수 있습니다. SSH 키 관리에서 개인 키를 보호하는 것과 동일한 수준으로 루트 키를 보호하십시오. 서비스에 공인 DNS 이름이 있다면 이 모든 과정을 생략하고 공인 CA를 사용하십시오. Nginx와 Let's Encrypt를 사용하는 Certbot은 작업량이 훨씬 적으며 클라이언트 측에 별도의 설치가 필요하지 않습니다.
CA 키 및 루트 인증서 생성
사용자 본인만 열 수 있는 디렉터리에서 작업하십시오. 루트 키는 해당 디렉터리를 절대 벗어나지 않아야 합니다.
install -d -m 700 ~/ca
cd ~/ca
openssl genrsa -aes256 -out ca.key 4096
chmod 600 ca.key-aes256는 사용자가 선택한 암호로 키를 암호화하며, 이후 이 키로 서명하는 모든 명령어는 해당 암호를 요구합니다. -aes256 옵션을 생략하면 키가 평문으로 디스크에 저장되므로, 백업 파일이나 다른 관리자 계정만으로도 귀하의 장비가 신뢰하는 인증서를 누군가 발급할 수 있게 됩니다.
이제 CA 키가 스스로 서명하는 루트 인증서를 생성합니다.
openssl req -x509 -new -key ca.key -sha256 -days 3650 \
-subj "/O=Example Internal/CN=Example Internal Root CA" \
-addext "basicConstraints=critical,CA:TRUE,pathlen:0" \
-addext "keyUsage=critical,keyCertSign,cRLSign" \
-addext "subjectKeyIdentifier=hash" \
-addext "nameConstraints=critical,permitted;DNS:internal.example" \
-out ca.crtinternal.example를 실제 사용하는 이름 접미사로 바꾸고, 마지막 확장자를 유지하기 전에 다음 섹션을 읽어 보십시오.
각 확장자는 하나의 역할을 수행합니다.
CA:TRUE이 포함된basicConstraints는 이 인증서를 CA 인증서로 만드는 요소입니다. 이 설정이 없으면 클라이언트는 서명 자체가 올바르더라도 이 키로 서명된 모든 인증서를 거부합니다.pathlen:0은 해당 CA가 리프 인증서에 서명할 수 있으며, 그 하위의 추가 CA는 생성할 수 없음을 명시합니다.keyUsage은 키의 용도를 인증서 및 폐기 목록 서명으로 제한하여, 실수로 동일한 키가 TLS 서버 키로 사용되는 것을 방지합니다.subjectKeyIdentifier는 리프 인증서가 참조할 식별자를 루트에 부여합니다. 클라이언트는 수백 개의 인증서가 저장된 저장소에서 올바른 발급자를 이 식별자를 통해 찾습니다.nameConstraints은 이 CA가 보증할 수 있는 이름을 제한합니다.
명령어가 의도대로 실행되었다고 가정하지 말고, 생성된 내용을 다시 확인하십시오.
openssl x509 -noout -subject -issuer -serial -dates -in ca.crt
openssl x509 -noout -text -in ca.crt루트 인증서는 스스로 서명하므로 주체(Subject)와 발급자(Issuer)는 동일한 문자열을 출력합니다. 일련번호와 두 날짜는 방금 생성한 파일에서 가져온 것이므로, 다른 가이드의 값을 복사하지 말고 해당 출력값에서 직접 확인하십시오.
CA가 서명할 수 있는 범위를 제한하기
시스템 저장소에 있는 루트 인증서는 별도의 설정이 없다면 인터넷상의 모든 도메인에 대해 신뢰를 가집니다. 이는 서버 한 대의 파일 하나가 가지기에는 지나치게 큰 권한입니다. nameConstraints은 이를 줄여줍니다. 루트 인증서에 permitted;DNS:internal.example를 포함하면, internal.example 외부의 도메인에 대해 해당 CA로부터 발급된 인증서 체인은 서명이 올바르더라도 거부됩니다.
이를 신뢰하기 전에 테스트를 수행하십시오.
openssl req -new -newkey rsa:2048 -nodes -keyout /tmp/outside.key -out /tmp/outside.csr \
-subj "/CN=www.example.com"
openssl x509 -req -in /tmp/outside.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 30 -sha256 \
-extfile <(printf 'subjectAltName=DNS:www.example.com\n') -out /tmp/outside.crt
openssl verify -CAfile ca.crt /tmp/outside.crt
echo $?CA는 요청하는 모든 것에 서명하므로 인증서는 정상적으로 발급됩니다. 하지만 검증 단계에서 실패하게 됩니다. 종료 상태 코드가 0이 아니며, OpenSSL은 위반된 제약 조건을 명시합니다. 이것이 바로 해당 확장 기능의 가치입니다. CA 키를 탈취당하더라도 하위 트리 외부의 도메인에 대해서는 유효한 인증서를 생성할 수 없습니다. 작업이 끝나면 rm /tmp/outside.*를 사용하여 생성된 파일들을 삭제하십시오.
제약 조건을 적용하기 전에 알아두어야 할 네 가지 사항이 있습니다. 이 설정은 critical로 표시되므로, 해당 확장 기능을 이해하지 못하는 클라이언트는 이를 무시하는 대신 체인을 거부해야 합니다. 이는 안전한 방향이지만, 오래된 TLS 라이브러리에서는 예상치 못한 오류가 발생할 수 있습니다. DNS 이름에 대한 허용된 하위 트리는 IP 주소 SAN을 제한하지 않습니다. 하위 트리가 나열되지 않은 이름 유형은 제한되지 않은 상태로 남기 때문입니다. 따라서 인증서에 IP 주소가 포함되어 있다면 동일한 확장 기능에 permitted;IP:10.0.0.0/255.255.0.0을 추가하십시오. 하위 트리는 짧은 호스트 이름을 포함하여 앞으로 발급할 모든 이름을 포괄해야 합니다. 예를 들어, app과 같은 단일 이름에 대한 인증서는 위의 예시 설정에서는 실패하게 됩니다. 또한 이 제약 조건은 루트 인증서에 고정되므로, 설정을 변경하려면 새로운 루트 인증서를 생성하고 모든 클라이언트에 새로 설치해야 합니다.
CA로 서명된 리프 인증서 발급하기
리프 인증서는 서버가 클라이언트에 제시하는 인증서입니다. 먼저 리프 인증서 전용 키와 CSR(인증서 서명 요청)을 생성하십시오. CSR에는 공개 키와 요청하는 도메인 이름이 포함되며, 요청자가 개인 키를 보유하고 있음을 증명하기 위해 리프 키로 서명합니다.
openssl req -new -newkey rsa:2048 -nodes \
-keyout app.key -out app.csr \
-subj "/CN=app.internal.example"
chmod 600 app.key중요한 도메인 이름은 CSR이 아니라 확장 파일에 기재해야 합니다. 클라이언트는 호스트 이름을 subjectAltName(SAN)과 대조하며 Common Name은 완전히 무시합니다. 따라서 CN만 있고 SAN이 없는 인증서는 CN 내용과 관계없이 모든 최신 클라이언트에서 호스트 이름 검증에 실패합니다.
basicConstraints = CA:FALSE
keyUsage = critical, digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
subjectAltName = DNS:app.internal.example, DNS:api.internal.example
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always해당 내용을 app.ext로 저장한 뒤, CA를 사용하여 요청에 서명하십시오.
openssl x509 -req -in app.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 397 -sha256 -extfile app.ext -out app.crt-CAcreateserial은 CA 옆에 ca.srl 파일을 생성하여 다음 일련번호를 기록하므로, 동일한 CA에서 발급된 인증서들이 일련번호를 공유하지 않게 합니다. 이 파일은 CA 디렉터리에 보관하십시오. -days 397는 도구의 제한이 아니라 선택 사항입니다. 사설 CA는 폐기 인프라(CRL이나 OCSP 응답자가 구축되지 않은 경우 없음)가 없으므로, 리프 키가 유출되면 인증서가 만료될 때까지 계속 사용할 수 있습니다. 따라서 공용 CA보다 짧은 유효 기간을 설정하는 것이 더 중요합니다.
신뢰 저장소(trust store)에 추가하기 전에 결과를 확인하십시오.
openssl x509 -noout -subject -issuer -serial -dates -in app.crt
openssl x509 -noout -ext subjectAltName -in app.crt이제 발급자(issuer) 항목에는 리프 자체가 아닌 CA의 이름이 표시됩니다. SAN 항목에는 이 인증서가 유효한 이름들이 나열되며, 클라이언트는 이 목록과만 대조 작업을 수행합니다.
설치 전 -CAfile을 명시하여 검증하기
openssl verify -CAfile ca.crt app.crt
echo $?이 명령은 단 한 가지 질문만 던집니다. app.crt이 ca.crt에 있는 인증서로 체인을 구성하는가? 이 명령은 시스템이 무엇을 신뢰하는지와는 무관합니다. 명령줄에서 직접 루트 인증서를 OpenSSL에 전달했기 때문입니다. 여기서 실패가 발생한다면 인증서 자체의 문제이므로, 다음 단계로 넘어가기 전에 이를 해결해야 합니다.
이제 시스템에 확인을 요청합니다.
openssl verify app.crt
echo $?-CAfile를 지정하지 않으면 OpenSSL은 내장된 인증서 디렉터리를 참조합니다. openssl version -d은 빌드 시 사용된 기본 디렉터리를 출력하며, Ubuntu에서는 그 아래의 certs 디렉터리가 /etc/ssl/certs로 연결됩니다. 아직 루트 인증서가 해당 위치에 없으므로 검증은 실패합니다. 체인이 저장소에 없는 발행자까지 도달하지만, 더 이상 확인할 곳이 없기 때문입니다. 종료 상태(exit status)를 확인하십시오. 이 값이 두 단계 뒤에 변경될 항목입니다.
실제 클라이언트는 openssl verify보다 더 나은 테스트 도구입니다. 체인뿐만 아니라 호스트 이름까지 확인하기 때문입니다. 인증서를 서비스하고 이를 가져와 봅니다.
openssl s_server -accept 8443 -cert app.crt -key app.key -www &
curl --resolve app.internal.example:8443:127.0.0.1 https://app.internal.example:8443/--resolve은 app.internal.example을 요청하면서 연결 대상은 127.0.0.1로 보냅니다. 따라서 SAN은 일치하며 남은 유일한 문제는 신뢰 여부입니다. curl은 실패하며 체인을 검증할 수 없었던 이유를 출력합니다. 더 자세한 내용을 보려면 -v를 추가하십시오. 테스트 서버는 계속 실행해 두십시오.
루트 인증서를 /usr/local/share/ca-certificates에 설치
sudo cp ca.crt /usr/local/share/ca-certificates/example-internal-root.crt
sudo chmod 644 /usr/local/share/ca-certificates/example-internal-root.crt
sudo update-ca-certificates이 작업의 성공 여부를 결정하는 세부 사항은 다음과 같습니다.
- 파일 이름은 반드시
.crt으로 끝나야 합니다.update-ca-certificates매뉴얼 페이지에 따르면/usr/local/share/ca-certificates하위에서 발견된.crt확장자를 가진 인증서만 포함되며 암묵적으로 신뢰됩니다.root.pem또는root.cer이라는 이름의 파일은 아무런 알림 없이 건너뜁니다. - 내용은 반드시 PEM 형식이어야 하며, 이는
BEGIN CERTIFICATE및END CERTIFICATE라인으로 감싸진 base64 블록입니다..crt로 이름만 바꾼 DER 파일은 여전히 바이너리 상태이므로 읽을 수 없습니다.openssl x509 -inform DER -in ca.der -out ca.crt를 사용하여 변환하십시오. - 루트 인증서만 이곳에 위치해야 합니다. CA 개인 키나 리프 인증서는 신뢰 저장소에 포함되어서는 안 됩니다.
update-ca-certificates은 추가 및 제거된 인증서의 개수를 출력합니다. 만약 추가된 인증서가 없다면 확장자나 파일 형식이 원인입니다.
해당 메시지보다는 시스템 측면에서 변경 사항을 확인하십시오.
ls -l /etc/ssl/certs/$(openssl x509 -noout -subject_hash -in /usr/local/share/ca-certificates/example-internal-root.crt).0
grep -c 'BEGIN CERTIFICATE' /etc/ssl/certs/ca-certificates.crt첫 번째 명령어는 인증서의 주체 해시(subject hash)를 사용하여 파일 이름을 생성하고 나열합니다. update-ca-certificates는 해당 심볼릭 링크를 생성하며, 이 링크는 설치한 파일을 가리킵니다. 두 번째 명령어는 단일 파일 번들에 포함된 인증서 개수를 셉니다. 설치 전에도 이 명령어를 실행하여 개수가 1만큼 증가하는지 확인할 수 있습니다.
이 루트 인증서를 다른 머신으로 복사할 때는 설치하기 전에 복사본이 온전한지 확인하십시오. 루트 인증서는 시스템에서 잘못될 경우 가장 치명적인 파일이므로, 사용하기 전에 체크섬으로 검증하는 다른 다운로드 파일과 동일하게 취급하십시오.
시스템 저장소를 기준으로 다시 확인
openssl verify app.crt
echo $?
curl --resolve app.internal.example:8443:127.0.0.1 https://app.internal.example:8443/동일한 명령어와 동일한 인증서 파일을 사용해도 결과는 다릅니다. app.crt와 관련하여 변경된 사항은 없으며, 서버는 앞서 시작한 것과 동일합니다. 유일한 차이점은 이제 클라이언트가 참조하는 저장소에 루트 인증서가 위치하여 인증서 체인이 완성된다는 점입니다. 이 메커니즘을 기억해야 합니다. 검증이란 클라이언트가 이미 신뢰하는 발급자를 찾아가는 과정이며, CA를 설치하는 것은 해당 발급자를 클라이언트가 검색하는 위치에 등록하는 작업입니다.
kill %1을 사용하여 테스트 서버를 중지합니다.
왜 /etc/ssl/certs에 파일을 넣으면 안 되는가
/etc/ssl/certs은 생성된 출력물입니다. update-ca-certificates은 실제 인증서 파일을 가리키는 심볼릭 링크로 이 디렉터리를 채우고, 그 옆에 통합 번들인 /etc/ssl/certs/ca-certificates.crt를 작성합니다.
수동으로 해당 디렉터리에 복사한 인증서는 그 어떤 프로세스도 찾지 못합니다. OpenSSL의 디렉터리 조회 기능은 인증서의 주체 해시(subject hash) 이름으로 된 파일만 열기 때문에, myca.crt과 같은 이름의 파일은 인식되지 않습니다. Ubuntu의 curl은 번들 파일을 읽는데, 이 번들은 등록된 소스에서 다시 빌드되므로 사용자가 복사한 파일은 해당 경로에 포함되지 않습니다. update-ca-certificates --fresh을 실행하면 디렉터리 내의 심볼릭 링크가 삭제 후 재빌드되며, 이때 사용자가 수동으로 만든 링크도 함께 사라집니다.
이 구조의 나머지 절반은 /usr/share/ca-certificates인데, 이는 ca-certificates 패키지에 속하며 /etc/ca-certificates.conf에 나열되어 있습니다. 패키지가 업데이트되면 이 파일은 덮어쓰기 됩니다. /usr/local/share/ca-certificates는 로컬 관리자를 위해 예약된 디렉터리이므로, 이곳에 저장한 CA 인증서는 나머지 파일을 관리하는 패키지가 업그레이드되어도 안전하게 유지됩니다.
시스템 신뢰 저장소를 무시하는 프로그램
루트 인증서를 설치하면 OpenSSL을 사용하거나 /etc/ssl/certs을 읽는 모든 프로그램의 문제가 해결됩니다. 여기에는 curl, wget, git, Python의 표준 ssl 모듈, 그리고 리눅스에서 시스템 파일을 읽는 Go 프로그램이 포함됩니다. 자체 인증서 목록을 포함하여 배포되는 런타임은 영향을 받지 않으며, 설치 성공 후 발생하는 대부분의 혼란은 여기서 비롯됩니다.
- Node.js는 컴파일된 목록을 사용합니다. 프로세스가 시작되기 전에 환경 변수로
NODE_EXTRA_CA_CERTS=/usr/local/share/ca-certificates/example-internal-root.crt을 설정하여 루트 인증서를 지정하십시오. Node는 시작 시점에 이 변수를 한 번만 읽기 때문입니다. 최신 Node 릴리스에는 시스템 저장소를 읽는 옵션이 포함되어 있습니다.node --help | grep -i system-ca를 실행하여 사용 중인 버전에 해당 기능이 있는지 확인하십시오. - Python의
requests라이브러리는certifi번들을 사용합니다. 해당 프로세스에 대해REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt를 설정하거나 호출 시verify="/etc/ssl/certs/ca-certificates.crt"을 전달하십시오.pip도 같은 이유로--cert를 사용합니다. - Java는 키 저장소를 읽습니다. Ubuntu의
ca-certificates-java패키지는/etc/ca-certificates/update.d/아래에 훅을 설치하므로, 해당 패키지가 존재하면update-ca-certificates이 Java 키 저장소도 함께 갱신합니다. 패키지가 없다면keytool -importcert를 사용하여 루트 인증서를 가져오십시오. - Firefox는 자체 저장소를 유지하며
/etc/ssl/certs을 참조하지 않습니다. 인증서 설정 메뉴를 통해 가져오십시오. 리눅스의 Chromium은 사용자별 NSS 데이터베이스를 읽으며, 이는libnss3-tools패키지의certutil을 사용하여 편집할 수 있습니다. - 컨테이너는 자체 파일 시스템을 가지므로 호스트의 저장소는 컨테이너 내부에서 아무런 의미가 없습니다. 루트 인증서를 이미지로 복사하고 빌드 중에
update-ca-certificates을 실행하십시오. 서비스가 VPS에서의 Docker Compose 환경에서 실행된다면 이를 미리 계획해야 합니다.
깔끔하게 설치한 후에도 프로그램이 여전히 인증서를 거부한다면, 설정을 변경하기 전에 해당 프로그램이 어떤 파일을 여는지 확인하십시오. strace -f -e trace=openat <command> 2>&1 | grep -i cert는 직관적인 도구이며, 한 번의 실행으로 답을 얻을 수 있습니다.
CA를 장기간 유지하는 방법
리프 인증서를 재발급하는 과정은 동일한 app.ext 파일을 사용하여 CSR 단계와 서명 단계를 다시 수행하는 것입니다. 클라이언트는 신뢰하는 루트 인증서가 변경되지 않았으므로 별도의 조치를 취할 필요가 없습니다. 다음 발급 시 기억에 의존하여 재구성하지 않도록 ca.srl 및 모든 .ext 파일을 CA 디렉터리에 보관하십시오.
ca.key 및 ca.crt는 암호화된 상태로 서버 외부의 안전한 장소에 백업하십시오. 키를 분실하면 새로운 인증서를 발급할 수 없으며, 두 번째 CA를 구축하여 첫 번째 CA가 설치된 모든 곳에 새 루트 인증서를 다시 설치해야 합니다. 루트 인증서를 배포한 모든 서버와 애플리케이션 저장소 목록을 문서로 유지하십시오. 이 목록이 있어야만 추후 인증서 교체 및 제거가 가능합니다.
루트 인증서 자체가 만료될 시점이 다가오면, 교체용 인증서를 미리 생성하여 기존 루트 인증서와 함께 설치하십시오. 저장소에 두 개의 루트 인증서가 있어도 문제가 없으며, 클라이언트는 둘 중 하나만 유효하면 인증을 수락합니다. 새 루트 인증서를 기준으로 리프 인증서를 재발급한 뒤, 기존 루트 인증서에 의존하는 서비스가 없음을 확인하면 이전 인증서를 제거하십시오.
신뢰 저장소에서 CA 제거하기
sudo rm /usr/local/share/ca-certificates/example-internal-root.crt
sudo update-ca-certificates --fresh--fresh은(는) /etc/ssl/certs 내의 심볼릭 링크를 제거하고 남아 있는 소스 파일로부터 링크를 다시 생성하므로, 삭제된 루트 인증서는 디렉터리와 번들에서 함께 사라집니다. 설치를 확인했던 것과 동일한 방식으로 제거 여부를 확인하십시오.
openssl verify app.crt
echo $?
grep -c 'BEGIN CERTIFICATE' /etc/ssl/certs/ca-certificates.crt
ls -l /etc/ssl/certs/$(openssl x509 -noout -subject_hash -in ~/ca/ca.crt).0검증이 다시 실패하고 인증서 개수가 원래대로 돌아오며, 해시 심볼릭 링크가 사라진 것을 확인할 수 있습니다.
해당 명령어는 시스템 저장소만 수정합니다. 다른 위치에 수행한 설치 작업은 수동으로 되돌려야 합니다. NODE_EXTRA_CA_CERTS를 비우고, Java 키 저장소에서 별칭을 삭제하며, 각 브라우저 프로필에서 루트 인증서를 제거하고, 이를 포함하여 빌드된 컨테이너 이미지를 다시 생성하십시오. 루트 인증서를 제거한다고 해서 해당 인증서로 서명된 인증서들이 무효화되는 것은 아닙니다. 여전히 해당 루트를 신뢰하는 모든 기기에서 인증서는 유효하게 유지되는데, 이것이 바로 사설 CA를 운영할 때 루트 인증서가 배포된 위치를 기록해 두어야 하는 실질적인 이유입니다. 완전히 철회할 수 없는 CA는 영구적인 보안 구멍이 되므로, 배포 목록이 짧을 때 한 대의 기기에서 미리 제거 테스트를 수행하십시오.
FAQ
Where do I put a CA certificate on Ubuntu?
In /usr/local/share/ca-certificates/, with a filename ending in .crt and PEM content, then run sudo update-ca-certificates. That directory is reserved for the local administrator, so package upgrades leave it alone. /usr/share/ca-certificates belongs to the ca-certificates package, and /etc/ssl/certs is generated from both, so a file placed in either of those is overwritten or ignored.
Why does curl still reject the certificate after update-ca-certificates?
Work through the causes in order. The file may not end in .crt, or may be DER rather than PEM, in which case update-ca-certificates skipped it and added nothing. The certificate may have no subjectAltName matching the hostname, which is a hostname failure rather than a trust failure; check with openssl x509 -noout -ext subjectAltName -in app.crt. The server may be sending only the leaf when an intermediate is also needed. curl may be pointed at a different bundle by CURL_CA_BUNDLE or --cacert. And a long-running service needs a restart, because most programs read the trust store once when they start.
Does the system trust store cover Firefox, Chrome, Node and Java?
No. curl, wget, git, Python's standard ssl module and Go programs read the system files, so those work as soon as update-ca-certificates runs. Firefox keeps its own store. Chromium on Linux uses a per-user NSS database, edited with certutil from the libnss3-tools package. Node.js needs NODE_EXTRA_CA_CERTS pointing at your root file. Java reads a keystore, which update-ca-certificates refreshes only when the ca-certificates-java package is installed. Python's requests uses certifi and needs REQUESTS_CA_BUNDLE.
How do I remove a CA from Ubuntu's trust store?
Delete the file from /usr/local/share/ca-certificates/ and run sudo update-ca-certificates --fresh. The --fresh option clears the symlinks in /etc/ssl/certs and rebuilds them, so the certificate leaves the hash symlinks and the ca-certificates.crt bundle at the same time. Confirm by running openssl verify against a certificate that CA signed and reading the exit status. Then repeat the removal in every other store you added it to, because that command does not touch any of them.
Can I use a private CA instead of Let's Encrypt for a public site?
No. A visitor's browser has never seen your root, so it shows a full-page warning, and you cannot install your root on machines you do not control. A private CA is for names that only your own machines resolve and for clients you administer. For anything a stranger visits, get the certificate from a public CA.